diff --git a/cmd/objgitd/example_hook_test.go b/cmd/objgitd/example_hook_test.go index 0e9ffc6..fe58d87 100644 --- a/cmd/objgitd/example_hook_test.go +++ b/cmd/objgitd/example_hook_test.go @@ -49,7 +49,7 @@ func TestExampleHookRuns(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - go func() { _ = d.Serve(ctx, ln) }() + go func() { _ = d.ServeGitProtocol(ctx, ln) }() remote := "git://" + ln.Addr().String() + "/example.git" work := t.TempDir() diff --git a/cmd/objgitd/git_protocol.go b/cmd/objgitd/git_protocol.go index 8ea37a3..f72c8ef 100644 --- a/cmd/objgitd/git_protocol.go +++ b/cmd/objgitd/git_protocol.go @@ -4,19 +4,14 @@ import ( "context" "errors" "fmt" - "io" "log/slog" - "net" "net/url" - "strings" "time" "github.com/go-git/go-billy/v6" git "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/cache" - "github.com/go-git/go-git/v6/plumbing/format/pktline" - "github.com/go-git/go-git/v6/plumbing/protocol/packp" "github.com/go-git/go-git/v6/plumbing/transport" "github.com/go-git/go-git/v6/storage" "github.com/go-git/go-git/v6/storage/filesystem" @@ -59,124 +54,6 @@ type daemon struct { hookTimeout time.Duration } -// Serve accepts connections on l until ctx is cancelled or Accept fails. -func (d *daemon) Serve(ctx context.Context, l net.Listener) error { - go func() { - <-ctx.Done() - _ = l.Close() - }() - - for { - conn, err := l.Accept() - if err != nil { - if ctx.Err() != nil { - return nil - } - return fmt.Errorf("objgitd: accept: %w", err) - } - - go func() { - if err := d.handle(ctx, conn); err != nil { - slog.Error("connection failed", - "remote", conn.RemoteAddr().String(), - "err", err, - ) - } - }() - } -} - -// handle services a single git:// connection: decode the request line, resolve -// the repository, and hand the socket to the matching server command. -func (d *daemon) handle(ctx context.Context, conn net.Conn) error { - defer conn.Close() - - // A silent client must not be able to pin a goroutine forever. - _ = conn.SetReadDeadline(time.Now().Add(handshakeTimeout)) - - var req packp.GitProtoRequest - if err := req.Decode(conn); err != nil { - return fmt.Errorf("decoding git-proto-request: %w", err) - } - - // The transfer that follows can take a while; drop the handshake deadline. - _ = conn.SetReadDeadline(time.Time{}) - - slog.Info("serving request", - "service", req.RequestCommand, - "path", req.Pathname, - "remote", conn.RemoteAddr().String(), - ) - - // ExtraParams carries e.g. "version=2"; transport.ProtocolVersion splits on ":". - gitProtocol := strings.Join(req.ExtraParams, ":") - - // UploadPack/ReceivePack call r.Close() between negotiation rounds, so the - // reader must be a no-op closer or the socket dies mid-conversation. The - // writer is the raw conn: its final Close() ends the connection. - r := io.NopCloser(conn) - - defer metrics.TrackInFlight("git")() - start := time.Now() - - if d.authorize(ctx, auth.Request{ - Repo: req.Pathname, - Operation: operationFor(req.RequestCommand), - Cred: auth.Anonymous{}, - Transport: "git", - }) != auth.Allow { - metrics.ObserveGitOp("git", req.RequestCommand, "denied", start) - _, _ = pktline.WriteError(conn, fmt.Errorf("access denied")) - return fmt.Errorf("access denied for %q (%s)", req.Pathname, req.RequestCommand) - } - - err := d.serveGit(ctx, conn, r, req, gitProtocol) - status := "ok" - if err != nil { - status = "error" - } - metrics.ObserveGitOp("git", req.RequestCommand, status, start) - return err -} - -// serveGit dispatches a parsed, authorized git:// request to the matching -// go-git transport command. -func (d *daemon) serveGit(ctx context.Context, conn net.Conn, r io.ReadCloser, req packp.GitProtoRequest, gitProtocol string) error { - switch req.RequestCommand { - case transport.UploadPackService: - st, err := d.load(req.Pathname) - if err != nil { - _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname)) - return fmt.Errorf("loading %q: %w", req.Pathname, err) - } - return transport.UploadPack(ctx, st, r, conn, &transport.UploadPackRequest{ - GitProtocol: gitProtocol, - }) - - case transport.UploadArchiveService: - st, err := d.load(req.Pathname) - if err != nil { - _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname)) - return fmt.Errorf("loading %q: %w", req.Pathname, err) - } - return transport.UploadArchive(ctx, st, r, conn, &transport.UploadArchiveRequest{}) - - case transport.ReceivePackService: - st, err := d.loadOrInit(req.Pathname) - if err != nil { - _, _ = pktline.WriteError(conn, fmt.Errorf("cannot open repository %q", req.Pathname)) - return fmt.Errorf("opening %q for push: %w", req.Pathname, err) - } - return d.receivePack(ctx, st, req.Pathname, r, conn, &transport.ReceivePackRequest{ - GitProtocol: gitProtocol, - }) - - default: - _, _ = pktline.WriteError(conn, fmt.Errorf("unsupported service %q", req.RequestCommand)) - return fmt.Errorf("unsupported service: %s", req.RequestCommand) - } -} - // load opens the storer for repoPath and heals a dangling HEAD before returning // it (see ensureHEAD). It preserves the loader's error verbatim — notably // transport.ErrRepositoryNotFound, which callers map to a 404 — and treats a diff --git a/cmd/objgitd/git_protocol_test.go b/cmd/objgitd/git_protocol_test.go index e3013a2..1e48455 100644 --- a/cmd/objgitd/git_protocol_test.go +++ b/cmd/objgitd/git_protocol_test.go @@ -2,6 +2,9 @@ package main import ( "context" + "fmt" + "io" + "log/slog" "net" "os" "os/exec" @@ -12,10 +15,131 @@ import ( "github.com/go-git/go-billy/v6" "github.com/go-git/go-billy/v6/memfs" + "github.com/go-git/go-git/v6/plumbing/format/pktline" + "github.com/go-git/go-git/v6/plumbing/protocol/packp" "github.com/go-git/go-git/v6/plumbing/transport" "github.com/tigrisdata/objgit/internal/auth" + "github.com/tigrisdata/objgit/internal/metrics" ) +// ServeGitProtocol accepts connections on l until ctx is cancelled or Accept fails. +func (d *daemon) ServeGitProtocol(ctx context.Context, l net.Listener) error { + go func() { + <-ctx.Done() + _ = l.Close() + }() + + for { + conn, err := l.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("objgitd: accept: %w", err) + } + + go func() { + if err := d.handleGitProtocol(ctx, conn); err != nil { + slog.Error("connection failed", + "remote", conn.RemoteAddr().String(), + "err", err, + ) + } + }() + } +} + +// handleGitProtocol services a single git:// connection: decode the request line, resolve +// the repository, and hand the socket to the matching server command. +func (d *daemon) handleGitProtocol(ctx context.Context, conn net.Conn) error { + defer conn.Close() + + // A silent client must not be able to pin a goroutine forever. + _ = conn.SetReadDeadline(time.Now().Add(handshakeTimeout)) + + var req packp.GitProtoRequest + if err := req.Decode(conn); err != nil { + return fmt.Errorf("decoding git-proto-request: %w", err) + } + + // The transfer that follows can take a while; drop the handshake deadline. + _ = conn.SetReadDeadline(time.Time{}) + + slog.Info("serving request", + "service", req.RequestCommand, + "path", req.Pathname, + "remote", conn.RemoteAddr().String(), + ) + + // ExtraParams carries e.g. "version=2"; transport.ProtocolVersion splits on ":". + gitProtocol := strings.Join(req.ExtraParams, ":") + + // UploadPack/ReceivePack call r.Close() between negotiation rounds, so the + // reader must be a no-op closer or the socket dies mid-conversation. The + // writer is the raw conn: its final Close() ends the connection. + r := io.NopCloser(conn) + + defer metrics.TrackInFlight("git")() + start := time.Now() + + if d.authorize(ctx, auth.Request{ + Repo: req.Pathname, + Operation: operationFor(req.RequestCommand), + Cred: auth.Anonymous{}, + Transport: "git", + }) != auth.Allow { + metrics.ObserveGitOp("git", req.RequestCommand, "denied", start) + _, _ = pktline.WriteError(conn, fmt.Errorf("access denied")) + return fmt.Errorf("access denied for %q (%s)", req.Pathname, req.RequestCommand) + } + + err := d.serveGit(ctx, conn, r, req, gitProtocol) + status := "ok" + if err != nil { + status = "error" + } + metrics.ObserveGitOp("git", req.RequestCommand, status, start) + return err +} + +// serveGit dispatches a parsed, authorized git:// request to the matching +// go-git transport command. +func (d *daemon) serveGit(ctx context.Context, conn net.Conn, r io.ReadCloser, req packp.GitProtoRequest, gitProtocol string) error { + switch req.RequestCommand { + case transport.UploadPackService: + st, err := d.load(req.Pathname) + if err != nil { + _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname)) + return fmt.Errorf("loading %q: %w", req.Pathname, err) + } + return transport.UploadPack(ctx, st, r, conn, &transport.UploadPackRequest{ + GitProtocol: gitProtocol, + }) + + case transport.UploadArchiveService: + st, err := d.load(req.Pathname) + if err != nil { + _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname)) + return fmt.Errorf("loading %q: %w", req.Pathname, err) + } + return transport.UploadArchive(ctx, st, r, conn, &transport.UploadArchiveRequest{}) + + case transport.ReceivePackService: + st, err := d.loadOrInit(req.Pathname) + if err != nil { + _, _ = pktline.WriteError(conn, fmt.Errorf("cannot open repository %q", req.Pathname)) + return fmt.Errorf("opening %q for push: %w", req.Pathname, err) + } + return d.receivePack(ctx, st, req.Pathname, r, conn, &transport.ReceivePackRequest{ + GitProtocol: gitProtocol, + }) + + default: + _, _ = pktline.WriteError(conn, fmt.Errorf("unsupported service %q", req.RequestCommand)) + return fmt.Errorf("unsupported service: %s", req.RequestCommand) + } +} + // TestDaemonPushCreatesRepo reproduces "git push git://host/new.git" against a // path that does not exist yet. The daemon must create the bare repository on // demand and the result must clone back cleanly. @@ -40,7 +164,7 @@ func TestDaemonPushCreatesRepo(t *testing.T) { } srvErr := make(chan error, 1) - go func() { srvErr <- d.Serve(ctx, ln) }() + go func() { srvErr <- d.ServeGitProtocol(ctx, ln) }() remote := "git://" + ln.Addr().String() + "/test.git" @@ -95,7 +219,7 @@ func TestDaemonPushDisabled(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - go func() { _ = d.Serve(ctx, ln) }() + go func() { _ = d.ServeGitProtocol(ctx, ln) }() remote := "git://" + ln.Addr().String() + "/test.git" @@ -139,7 +263,7 @@ func TestDaemonPushKeepsPack(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - go func() { _ = d.Serve(ctx, ln) }() + go func() { _ = d.ServeGitProtocol(ctx, ln) }() remote := "git://" + ln.Addr().String() + "/test.git" diff --git a/cmd/objgitd/hooks_test.go b/cmd/objgitd/hooks_test.go index 7f91ff1..90aa06f 100644 --- a/cmd/objgitd/hooks_test.go +++ b/cmd/objgitd/hooks_test.go @@ -120,7 +120,7 @@ func TestReceivePackHook(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - go func() { _ = d.Serve(ctx, ln) }() + go func() { _ = d.ServeGitProtocol(ctx, ln) }() remote := "git://" + ln.Addr().String() + "/hooked.git" @@ -200,7 +200,7 @@ func TestReceivePackHookAbsent(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - go func() { _ = d.Serve(ctx, ln) }() + go func() { _ = d.ServeGitProtocol(ctx, ln) }() remote := "git://" + ln.Addr().String() + "/plain.git" work := t.TempDir() diff --git a/cmd/objgitd/main.go b/cmd/objgitd/main.go index ed28680..5db8fe5 100644 --- a/cmd/objgitd/main.go +++ b/cmd/objgitd/main.go @@ -32,7 +32,6 @@ import ( ) var ( - gitBind = flag.String("git-bind", ":9418", "TCP address to listen on for the git:// protocol; empty disables it") httpBind = flag.String("http-bind", ":8080", "TCP address to listen on for the git smart-HTTP protocol; empty disables it") sshBind = flag.String("ssh-bind", "", "TCP address to listen on for the git-over-SSH protocol; empty disables it") metricsBind = flag.String("metrics-bind", ":9090", "TCP address to serve the Prometheus /metrics endpoint; empty disables it") @@ -70,8 +69,8 @@ func main() { os.Exit(1) } - if *gitBind == "" && *httpBind == "" && *sshBind == "" { - slog.Error("at least one of -git-bind, -http-bind, or -ssh-bind must be set") + if *httpBind == "" && *sshBind == "" { + slog.Error("at least one of -http-bind or -ssh-bind must be set") os.Exit(1) } @@ -143,7 +142,6 @@ func main() { slog.Info("objgitd listening", "version", objgit.Version, - "git_bind", *gitBind, "http_bind", *httpBind, "ssh_bind", *sshBind, "metrics_bind", *metricsBind, @@ -193,15 +191,6 @@ func main() { }) } - if *gitBind != "" { - ln, err := net.Listen("tcp", *gitBind) - if err != nil { - slog.Error("can't listen", "git_bind", *gitBind, "err", err) - os.Exit(1) - } - g.Go(func() error { return d.Serve(gCtx, ln) }) - } - if *httpBind != "" { ln, err := net.Listen("tcp", *httpBind) if err != nil {