diff --git a/README.md b/README.md index a645c54..48391ae 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,19 @@ Grack was written to allow far more webservers to handle Git smart http requests. The aim of this project is to improve Git smart http performance by utilising the power of Go. +## Features + +- Embeddable Go server implementing `http.Handler`, with independent configuration per instance. +- Git Smart HTTP clone, fetch, and push using native `git upload-pack` and `git receive-pack`. +- Bare repository creation in-process with go-git, including cleanup on initialization failure. +- Pluggable `Store` interface for opening, creating, deleting, checking, and listing repositories. +- Filesystem storage with nested repository namespaces and path traversal/symlink checks. +- HTTP Basic authentication across repository requests, configurable route prefixes, and Git command customization. +- Standalone command-line server and backward-compatible `server.Handler()` entry point. + +Custom stores currently need to expose a local repository path. Direct remote +object storage and a fully Go-based push/fetch engine are not yet implemented. + ## Dependencies - Go >= 1.25 to build or embed the server diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 0000000..6f252a9 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,92 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestAuthenticationProtectsAllRepositoryEndpoints(t *testing.T) { + endpoints := []struct{ method, path string }{ + {"GET", "/example.git/info/refs?service=git-upload-pack"}, + {"GET", "/example.git/info/refs?service=git-receive-pack"}, + {"POST", "/example.git/git-upload-pack"}, + {"POST", "/example.git/git-receive-pack"}, + {"GET", "/example.git/info/refs"}, + {"GET", "/example.git/HEAD"}, + {"GET", "/example.git/objects/info/alternates"}, + {"GET", "/example.git/objects/info/http-alternates"}, + {"GET", "/example.git/objects/info/packs"}, + {"GET", "/example.git/objects/info/commit-graph"}, + {"GET", "/example.git/objects/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"GET", "/example.git/objects/pack/pack-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.pack"}, + {"GET", "/example.git/objects/pack/pack-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.idx"}, + } + for _, endpoint := range endpoints { + for _, credentials := range []string{"missing", "wrong-user", "wrong-password", "malformed"} { + t.Run(endpoint.method+endpoint.path+"/"+credentials, func(t *testing.T) { + store := &testStore{err: ErrRepositoryNotFound} + config := DefaultConfig + config.RequireAuth = true + config.AuthUserEnvVar = "user" + config.AuthPassEnvVar = "pass" + config.RoutePrefix = "/git" + srv := New(config, store) + req := httptest.NewRequest(endpoint.method, "/git"+endpoint.path, nil) + switch credentials { + case "wrong-user": + req.SetBasicAuth("wrong", "pass") + case "wrong-password": + req.SetBasicAuth("user", "wrong") + case "malformed": + req.Header.Set("Authorization", "Basic invalid") + } + if endpoint.method == "POST" { + rpc := "upload-pack" + if endpoint.path == "/example.git/git-receive-pack" { + rpc = "receive-pack" + } + req.Header.Set("Content-Type", "application/x-git-"+rpc+"-request") + } + res := httptest.NewRecorder() + srv.ServeHTTP(res, req) + if res.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", res.Code) + } + if got := res.Header().Get("WWW-Authenticate"); got != `Basic realm="authorization needed"` { + t.Fatalf("unexpected challenge %q", got) + } + if store.name != "" { + t.Fatalf("unauthenticated request opened repository %q", store.name) + } + }) + } + } +} + +func TestStaticRepositoryAuthentication(t *testing.T) { + dir := t.TempDir() + const head = "ref: refs/heads/main\n" + if err := os.WriteFile(filepath.Join(dir, "HEAD"), []byte(head), 0600); err != nil { + t.Fatal(err) + } + for _, requireAuth := range []bool{false, true} { + t.Run(map[bool]string{false: "public", true: "authenticated"}[requireAuth], func(t *testing.T) { + config := DefaultConfig + config.RequireAuth = requireAuth + config.AuthUserEnvVar = "user" + config.AuthPassEnvVar = "pass" + req := httptest.NewRequest("GET", "/example.git/HEAD", nil) + if requireAuth { + req.SetBasicAuth("user", "pass") + } + res := httptest.NewRecorder() + New(config, &testStore{repo: testRepository(dir)}).ServeHTTP(res, req) + if res.Code != http.StatusOK || res.Body.String() != head { + t.Fatalf("response = %d %q", res.Code, res.Body.String()) + } + }) + } +} diff --git a/server/repository_test.go b/server/repository_test.go index 2842534..dc4dd99 100644 --- a/server/repository_test.go +++ b/server/repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http/httptest" + "net/url" "os" "os/exec" "path/filepath" @@ -101,12 +102,27 @@ func (s *repositoryCreateErrorStore) Exists(context.Context, string) (bool, erro func (s *repositoryCreateErrorStore) List(context.Context) ([]string, error) { return nil, nil } func TestCreateRepositoryNativeHTTPPushAndClone(t *testing.T) { + for _, requireAuth := range []bool{false, true} { + name := "public" + if requireAuth { + name = "authenticated" + } + t.Run(name, func(t *testing.T) { testNativeHTTPPushAndClone(t, requireAuth) }) + } +} + +func testNativeHTTPPushAndClone(t *testing.T, requireAuth bool) { + t.Helper() + gitBin, err := exec.LookPath("git") if err != nil { t.Skip("native git is required for HTTP integration test") } config := DefaultConfig config.GitBinPath = gitBin + config.RequireAuth = requireAuth + config.AuthUserEnvVar = "user" + config.AuthPassEnvVar = "pass" srv := New(config, NewFilesystemStore(t.TempDir())) if _, err := srv.CreateRepository(context.Background(), "team/example.git"); err != nil { t.Fatal(err) @@ -135,10 +151,16 @@ func TestCreateRepositoryNativeHTTPPushAndClone(t *testing.T) { } run(source, "add", "hello.txt") run(source, "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "initial commit") - url := httpServer.URL + "/team/example.git" - run(source, "push", url, "master") + remoteURL, err := url.Parse(httpServer.URL + "/team/example.git") + if err != nil { + t.Fatal(err) + } + if requireAuth { + remoteURL.User = url.UserPassword("user", "pass") + } + run(source, "push", remoteURL.String(), "master") clone := filepath.Join(t.TempDir(), "clone") - run(source, "clone", url, clone) + run(source, "clone", remoteURL.String(), clone) if got, want := run(clone, "rev-parse", "HEAD"), run(source, "rev-parse", "HEAD"); got != want { t.Fatalf("cloned commit = %s, want %s", got, want) } diff --git a/server/server.go b/server/server.go index 3907059..b1d4f52 100644 --- a/server/server.go +++ b/server/server.go @@ -104,6 +104,15 @@ func Handler() http.HandlerFunc { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s %s %s", r.RemoteAddr, r.Method, r.URL.Path, r.Proto) + // Authenticate before opening storage or dispatching any Git HTTP endpoint. + if s.Config.RequireAuth { + user, password, ok := r.BasicAuth() + if !ok || user != s.Config.AuthUserEnvVar || password != s.Config.AuthPassEnvVar { + renderAuthRequire(w) + return + } + } + for match, service := range services { re, err := regexp.Compile(s.Config.RoutePrefix + match) if err != nil { @@ -255,18 +264,6 @@ func getInfoRefs(s *Server, hr HandlerReq) { access := s.hasAccess(r, dir, serviceName, false) version := r.Header.Get("Git-Protocol") - user, password, authok := r.BasicAuth() - if s.Config.RequireAuth { - if !authok { - renderAuthRequire(w) - return - } - if user != s.Config.AuthUserEnvVar || password != s.Config.AuthPassEnvVar { - renderAuthRequire(w) - return - } - } - if access { args := []string{serviceName, "--stateless-rpc", "--advertise-refs", "."} refs := s.gitCommand(r.Context(), dir, version, args...)