diff --git a/admin_test.go b/admin_test.go index 7ceaa82aab04103c71797c7197a7e44933d8e588..e100ebff3da37fb4ef079a2df2e29a1a62c05039 100644 --- a/admin_test.go +++ b/admin_test.go @@ -3,15 +3,19 @@ package main import ( "context" "crypto/tls" + "io/ioutil" "net" "net/http" "net/http/httptest" "os" + "path" + "path/filepath" "testing" "time" "github.com/stretchr/testify/require" gitalyauth "gitlab.com/gitlab-org/gitaly/auth" + pb "gitlab.com/gitlab-org/gitlab-pages-proto/go" "golang.org/x/net/http2" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -27,14 +31,9 @@ var ( ) func TestAdminUnixPermissions(t *testing.T) { - socketPath := "admin.socket" - // Use "../../" because the pages executable cd's into shared/pages - adminArgs := append(adminSecretArgs, "-admin-unix-listener", "../../"+socketPath) - teardown := RunPagesProcessWithoutWait(t, *pagesBinary, listeners, "", adminArgs...) + socketPath, teardown := startAdminUnix(t) defer teardown() - waitHTTP2RoundTripUnix(t, socketPath) - st, err := os.Stat(socketPath) require.NoError(t, err) expectedMode := os.FileMode(0777) @@ -42,14 +41,9 @@ func TestAdminUnixPermissions(t *testing.T) { } func TestAdminHealthCheckUnix(t *testing.T) { - socketPath := "admin.socket" - // Use "../../" because the pages executable cd's into shared/pages - adminArgs := append(adminSecretArgs, "-admin-unix-listener", "../../"+socketPath) - teardown := RunPagesProcessWithoutWait(t, *pagesBinary, listeners, "", adminArgs...) + socketPath, teardown := startAdminUnix(t) defer teardown() - waitHTTP2RoundTripUnix(t, socketPath) - testCases := []struct { desc string dialOpt grpc.DialOption @@ -144,6 +138,58 @@ func TestAdminHealthCheckHTTPS(t *testing.T) { } } +func TestAdminDeleteSite(t *testing.T) { + socketPath, teardown := startAdminUnix(t) + defer teardown() + + deleteName := "group/project-123-to-be-deleted" + deleteDir, err := filepath.Abs(path.Join(*pagesRoot, deleteName)) + require.NoError(t, err) + + deletePublic := path.Join(deleteDir, "public") + require.NoError(t, os.MkdirAll(deletePublic, 0755)) + require.NoError(t, ioutil.WriteFile(path.Join(deletePublic, "index.html"), nil, 0644)) + + _, err = os.Stat(deleteDir) + require.NoError(t, err, "sanity check: expected directory to exist after setup") + + connOpts := []grpc.DialOption{ + grpc.WithInsecure(), + grpcUnixDialOpt(), + grpc.WithPerRPCCredentials(gitalyauth.RPCCredentials(adminToken)), + } + + conn, err := grpc.Dial(socketPath, connOpts...) + require.NoError(t, err, "dial") + defer conn.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client := pb.NewDeployServiceClient(conn) + req := &pb.DeleteSiteRequest{Path: deleteName} + _, err = client.DeleteSite(ctx, req) + require.NoError(t, err) + + _, err = os.Stat(deleteDir) + require.True(t, os.IsNotExist(err), "expected directory to be removed") +} + +func startAdminUnix(t *testing.T) (socketPath string, teardown func()) { + socketPath = "admin.socket" + // Use "../../" because the pages executable cd's into shared/pages + adminArgs := append(adminSecretArgs, "-admin-unix-listener", "../../"+socketPath) + teardown = RunPagesProcessWithoutWait(t, *pagesBinary, listeners, "", adminArgs...) + + if err := waitHTTP2RoundTripUnix(socketPath); err != nil { + teardown() + t.Fatal(err) + return "", nil + } + + return socketPath, teardown +} + func newAddr() string { s := httptest.NewServer(http.NotFoundHandler()) s.Close() @@ -176,17 +222,17 @@ func grpcUnixDialOpt() grpc.DialOption { }) } -func waitHTTP2RoundTripUnix(t *testing.T, socketPath string) { +func waitHTTP2RoundTripUnix(socketPath string) error { var err error for start := time.Now(); time.Since(start) < 5*time.Second; time.Sleep(100 * time.Millisecond) { err = roundtripHTTP2Unix(socketPath) if err == nil { - return + return nil } } - t.Fatal(err) + return err } func roundtripHTTP2Unix(socketPath string) error { diff --git a/internal/admin/server.go b/internal/admin/server.go index e7c7cfe6a95e9f7c3fcfae81231658ee45b285f3..ce1a331e31483ab9543fc8218a5030c62816d298 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -9,6 +9,8 @@ import ( grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery" grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" log "github.com/sirupsen/logrus" + pb "gitlab.com/gitlab-org/gitlab-pages-proto/go" + "gitlab.com/gitlab-org/gitlab-pages/internal/service/deploy" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/health" @@ -59,5 +61,6 @@ func serverOpts(secret string) []grpc.ServerOption { } func registerServices(g *grpc.Server) { + pb.RegisterDeployServiceServer(g, deploy.NewServer()) healthpb.RegisterHealthServer(g, health.NewServer()) } diff --git a/internal/service/deploy/.gitignore b/internal/service/deploy/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ace1063ab026afd4eca6e8cf32336a7b4c3e3382 --- /dev/null +++ b/internal/service/deploy/.gitignore @@ -0,0 +1 @@ +/testdata diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go new file mode 100644 index 0000000000000000000000000000000000000000..ac0e2452fef26c6d9ac6ffbb2abb7109c4a4e340 --- /dev/null +++ b/internal/service/deploy/deploy.go @@ -0,0 +1,54 @@ +package deploy + +import ( + "os" + "regexp" + "strings" + + "github.com/golang/protobuf/ptypes/empty" + pb "gitlab.com/gitlab-org/gitlab-pages-proto/go" + "golang.org/x/net/context" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type server struct{} + +// NewServer returns a new deploy service server. +func NewServer() pb.DeployServiceServer { + return &server{} +} + +var traversalRegex = regexp.MustCompile(`(^\.\./)|(/\.\./)|(/\.\.$)`) + +func (s *server) DeleteSite(ctx context.Context, req *pb.DeleteSiteRequest) (*empty.Empty, error) { + if err := validatePath(req.Path); err != nil { + return nil, err + } + + st, err := os.Stat(req.Path) + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "request.Path: %v", err) + } + if !st.IsDir() { + return nil, status.Errorf(codes.FailedPrecondition, "not a directory: %q", req.Path) + } + + return &empty.Empty{}, os.RemoveAll(req.Path) +} + +func validatePath(requestPath string) error { + if requestPath == "" { + return status.Errorf(codes.InvalidArgument, "path empty") + } + + if traversalRegex.MatchString(requestPath) { + return status.Errorf(codes.InvalidArgument, "invalid path: %q", requestPath) + } + + if strings.IndexAny(requestPath, "./~") == 0 { + return status.Errorf(codes.InvalidArgument, "invalid path: %q", requestPath) + } + + return nil +} diff --git a/internal/service/deploy/deploy_test.go b/internal/service/deploy/deploy_test.go new file mode 100644 index 0000000000000000000000000000000000000000..156e333c89027e0ae1d4d09ad2f20c05f79aaedc --- /dev/null +++ b/internal/service/deploy/deploy_test.go @@ -0,0 +1,149 @@ +package deploy + +import ( + "context" + "io/ioutil" + "net" + "os" + "path" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + pb "gitlab.com/gitlab-org/gitlab-pages-proto/go" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + serverSocketPath = "../grpc.socket" + testRootDir = "testdata/root" +) + +var ( + cdRootOnce sync.Once +) + +// cdRoot changes the working directory of the test executable. We are +// forced to assume that the pages root directory is the current working +// directory. When running pages with chroot+bind mount, os.Getwd() +// resolves to a garbage "(undefined)" vaule. So in turn, the tests for +// this package must execute with the pages root as the working +// directory. +func cdRoot(t *testing.T) { + cdRootOnce.Do(func() { + require.NoError(t, os.Chdir(testRootDir)) + }) +} + +func TestDeleteSite(t *testing.T) { + cdRoot(t) + + sitePath, testSiteDir := setupTestSite(t) + require.NoError(t, ioutil.WriteFile(path.Join(testSiteDir, "hello"), []byte("world"), 0644)) + + s := runDeployServer(t) + defer s.Stop() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client, conn := newDeployClient(t) + defer conn.Close() + + _, err := client.DeleteSite(ctx, &pb.DeleteSiteRequest{Path: sitePath}) + require.NoError(t, err) + + _, err = os.Stat(testSiteDir) + require.True(t, os.IsNotExist(err), "directory should have been removed") + + _, err = os.Stat(path.Dir(testSiteDir)) + require.NoError(t, err, "parent directory should still exist") +} + +func setupTestSite(t *testing.T) (sitePath string, testSiteDir string) { + sitePath = "foo/bar" + testSiteDir, err := filepath.Abs(sitePath) + require.NoError(t, err) + require.NoError(t, os.RemoveAll(testSiteDir)) + require.NoError(t, os.MkdirAll(testSiteDir, 0755)) + + return sitePath, testSiteDir +} + +func TestDeleteSiteFail(t *testing.T) { + cdRoot(t) + + sitePath, testSiteDir := setupTestSite(t) + require.NoError(t, ioutil.WriteFile(path.Join(testSiteDir, "hello"), []byte("world"), 0644)) + + s := runDeployServer(t) + defer s.Stop() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client, conn := newDeployClient(t) + defer conn.Close() + + testCases := []struct { + desc string + path string + code codes.Code + }{ + {desc: "empty path", path: "", code: codes.InvalidArgument}, + {desc: "traversal beginning", path: "../foo", code: codes.InvalidArgument}, + {desc: "traversal middle", path: "bar/../foo", code: codes.InvalidArgument}, + {desc: "traversal end", path: "foo/bar/..", code: codes.InvalidArgument}, + {desc: "path starting with period", path: ".foo/bar", code: codes.InvalidArgument}, + {desc: "path starting with slash", path: "/foo/bar", code: codes.InvalidArgument}, + {desc: "path starting with tilde", path: "~/foo/bar", code: codes.InvalidArgument}, + {desc: "directory does not exist", path: "does/not/exist", code: codes.FailedPrecondition}, + {desc: "path is a file not a directory", path: path.Join(sitePath, "hello"), code: codes.FailedPrecondition}, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + req := &pb.DeleteSiteRequest{Path: tc.path} + + _, err := client.DeleteSite(ctx, req) + st, ok := status.FromError(err) + require.True(t, ok, "error has a grpc status") + require.Equal(t, tc.code, st.Code(), "unexpected grpc code") + }) + } +} + +func runDeployServer(t *testing.T) *grpc.Server { + grpcServer := grpc.NewServer() + + listener, err := net.Listen("unix", serverSocketPath) + + if err != nil { + t.Fatal(err) + } + + pb.RegisterDeployServiceServer(grpcServer, NewServer()) + + go grpcServer.Serve(listener) + + return grpcServer +} + +func newDeployClient(t *testing.T) (pb.DeployServiceClient, *grpc.ClientConn) { + connOpts := []grpc.DialOption{ + grpc.WithInsecure(), + grpc.WithDialer(func(addr string, timeout time.Duration) (net.Conn, error) { + return net.DialTimeout("unix", addr, timeout) + }), + } + conn, err := grpc.Dial(serverSocketPath, connOpts...) + if err != nil { + t.Fatal(err) + } + + return pb.NewDeployServiceClient(conn), conn +} diff --git a/internal/service/deploy/testdata/.gitkeep b/internal/service/deploy/testdata/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391