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
2 changes: 1 addition & 1 deletion cmd/objgitd/example_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
123 changes: 0 additions & 123 deletions cmd/objgitd/git_protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
130 changes: 127 additions & 3 deletions cmd/objgitd/git_protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package main

import (
"context"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/exec"
Expand All @@ -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.
Expand All @@ -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"

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions cmd/objgitd/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
Expand Down
15 changes: 2 additions & 13 deletions cmd/objgitd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading