Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions server/auth_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
28 changes: 25 additions & 3 deletions server/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
21 changes: 9 additions & 12 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
asim marked this conversation as resolved.
}
}

for match, service := range services {
re, err := regexp.Compile(s.Config.RoutePrefix + match)
if err != nil {
Expand Down Expand Up @@ -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...)
Expand Down
Loading