Something went wrong. Try again.
Git backed by object storage because you can't stop me
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292package main
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" "tangled.org/xeiaso.net/objgit/internal/auth" "tangled.org/xeiaso.net/objgit/internal/metrics")
// handshakeTimeout bounds how long a client has to send its git-proto-request.// It is cleared once the (possibly long) transfer begins.const handshakeTimeout = 30 * time.Second
// operationFor maps a git service to the access it needs: receive-pack writes,// everything else (upload-pack, upload-archive) reads.func operationFor(service string) auth.Operation { if service == transport.ReceivePackService { return auth.Write } return auth.Read}
// authorize is the single seam every transport routes authorization through: it// times the underlying Authorizer and records the decision (by transport,// operation, and outcome) before returning it. Each transport still renders the// Decision in its own dialect.func (d *daemon) authorize(ctx context.Context, req auth.Request) auth.Decision { start := time.Now() dec := d.authz.Authorize(ctx, req) metrics.ObserveAuth(req.Transport, req.Operation, dec, start) return dec}
// daemon serves the git:// (TCP) protocol out of a billy filesystem.type daemon struct { fs billy.Filesystem loader transport.Loader authz auth.Authorizer
// allowHooks gates running .objgit/hooks/receive-pack after a push. allowHooks bool 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// heal failure as non-fatal so a clone is never broken by a transient HEAD write.func (d *daemon) load(repoPath string) (storage.Storer, error) { st, err := d.loader.Load(&url.URL{Path: repoPath}) if err != nil { return nil, err } if err := ensureHEAD(st); err != nil { slog.Warn("could not repoint dangling HEAD", "path", repoPath, "err", err) } return st, nil}
// ensureHEAD repoints a repository's HEAD at an existing branch when its symbolic// target is missing. objgitd initializes every repo with HEAD -> refs/heads/main// (loadOrInit), but a repo populated by pushing a project whose default branch// differs — golang/go uses master — leaves HEAD dangling: clients fetch every// object yet cannot check out a worktree ("remote HEAD refers to nonexistent// ref"). Git hosts repoint HEAD on push; we heal idempotently on every load and// after each push, so repos already in the bucket recover on their next clone// without a re-push. A detached or already-valid HEAD, or a repo with no branches// yet, is left untouched.func ensureHEAD(st storage.Storer) error { head, err := st.Reference(plumbing.HEAD) if err != nil { return err } if head.Type() != plumbing.SymbolicReference { return nil // detached HEAD: nothing to repoint } if _, err := st.Reference(head.Target()); err == nil { return nil // target exists: HEAD is already valid } else if !errors.Is(err, plumbing.ErrReferenceNotFound) { return err } target, err := pickDefaultBranch(st) if err != nil || target == "" { return err // no branches yet (target == ""): leave HEAD as-is } return st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, target))}
// pickDefaultBranch chooses a branch for HEAD: prefer refs/heads/main, then// master, then trunk; otherwise the lexicographically smallest branch so the// choice is deterministic. Returns "" when the repo has no branches.func pickDefaultBranch(st storage.Storer) (plumbing.ReferenceName, error) { iter, err := st.IterReferences() if err != nil { return "", err } defer iter.Close()
rank := map[plumbing.ReferenceName]int{ plumbing.Main: 0, plumbing.Master: 1, plumbing.NewBranchReferenceName("trunk"): 2, } var ( first plumbing.ReferenceName best plumbing.ReferenceName bestRank = len(rank) ) err = iter.ForEach(func(r *plumbing.Reference) error { if r.Type() != plumbing.HashReference || !r.Name().IsBranch() { return nil } name := r.Name() if first == "" || name < first { first = name } if rk, ok := rank[name]; ok && rk < bestRank { best, bestRank = name, rk } return nil }) if err != nil { return "", err } if best != "" { return best, nil } return first, nil}
// loadOrInit returns the storer for repoPath, creating an empty bare repository// on demand. Git's daemon never auto-creates; objgitd does, so a first push to// a new path just works.func (d *daemon) loadOrInit(repoPath string) (storage.Storer, error) { st, err := d.load(repoPath) if err == nil { return st, nil } if !errors.Is(err, transport.ErrRepositoryNotFound) { return nil, err }
fs, err := d.fs.Chroot(repoPath) if err != nil { return nil, fmt.Errorf("chroot %q: %w", repoPath, err) }
st = filesystem.NewStorage(fs, cache.NewObjectLRUDefault()) if _, err := git.Init(st, git.WithDefaultBranch(plumbing.NewBranchReferenceName("main"))); err != nil { return nil, fmt.Errorf("init bare repo: %w", err) }
metrics.ReposCreated() slog.Info("created repository", "path", repoPath) return st, nil}