Git backed by object storage because you can't stop me
git
object-storage
kefka
1package main
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "net"
10 "net/url"
11 "strings"
12 "time"
13
14 "github.com/go-git/go-billy/v6"
15 git "github.com/go-git/go-git/v6"
16 "github.com/go-git/go-git/v6/plumbing"
17 "github.com/go-git/go-git/v6/plumbing/cache"
18 "github.com/go-git/go-git/v6/plumbing/format/pktline"
19 "github.com/go-git/go-git/v6/plumbing/protocol/packp"
20 "github.com/go-git/go-git/v6/plumbing/transport"
21 "github.com/go-git/go-git/v6/storage"
22 "github.com/go-git/go-git/v6/storage/filesystem"
23)
24
25// handshakeTimeout bounds how long a client has to send its git-proto-request.
26// It is cleared once the (possibly long) transfer begins.
27const handshakeTimeout = 30 * time.Second
28
29// streamingStorer wraps a storage.Storer to hide its optional
30// storer.PackfileWriter capability. go-git's UpdateObjectStorage drains the
31// incoming pack into PackfileWriter via io.CopyBuffer, which only returns on
32// io.EOF — fine over HTTP (the request body has a natural EOF) but a deadlock
33// over git://, where the client holds the connection open waiting for the
34// server's report-status. With PackfileWriter hidden, UpdateObjectStorage
35// falls through to Parser.Parse, which knows the end of the pack from the
36// pack format itself and never waits for an EOF.
37//
38// Trade-off: Parser.Parse writes loose objects (one Rename → one S3 PUT each)
39// instead of one packfile, so large git:// pushes incur more S3 calls. HTTP
40// keeps the fast PackfileWriter path.
41type streamingStorer struct {
42 storage.Storer
43}
44
45// daemon serves the git:// (TCP) protocol out of a billy filesystem.
46type daemon struct {
47 fs billy.Filesystem
48 loader transport.Loader
49 allowPush bool
50}
51
52// Serve accepts connections on l until ctx is cancelled or Accept fails.
53func (d *daemon) Serve(ctx context.Context, l net.Listener) error {
54 go func() {
55 <-ctx.Done()
56 _ = l.Close()
57 }()
58
59 for {
60 conn, err := l.Accept()
61 if err != nil {
62 if ctx.Err() != nil {
63 return nil
64 }
65 return fmt.Errorf("objgitd: accept: %w", err)
66 }
67
68 go func() {
69 if err := d.handle(ctx, conn); err != nil {
70 slog.Error("connection failed",
71 "remote", conn.RemoteAddr().String(),
72 "err", err,
73 )
74 }
75 }()
76 }
77}
78
79// handle services a single git:// connection: decode the request line, resolve
80// the repository, and hand the socket to the matching server command.
81func (d *daemon) handle(ctx context.Context, conn net.Conn) error {
82 defer conn.Close()
83
84 // A silent client must not be able to pin a goroutine forever.
85 _ = conn.SetReadDeadline(time.Now().Add(handshakeTimeout))
86
87 var req packp.GitProtoRequest
88 if err := req.Decode(conn); err != nil {
89 return fmt.Errorf("decoding git-proto-request: %w", err)
90 }
91
92 // The transfer that follows can take a while; drop the handshake deadline.
93 _ = conn.SetReadDeadline(time.Time{})
94
95 slog.Info("serving request",
96 "service", req.RequestCommand,
97 "path", req.Pathname,
98 "remote", conn.RemoteAddr().String(),
99 )
100
101 // ExtraParams carries e.g. "version=2"; transport.ProtocolVersion splits on ":".
102 gitProtocol := strings.Join(req.ExtraParams, ":")
103
104 // UploadPack/ReceivePack call r.Close() between negotiation rounds, so the
105 // reader must be a no-op closer or the socket dies mid-conversation. The
106 // writer is the raw conn: its final Close() ends the connection.
107 r := io.NopCloser(conn)
108
109 switch req.RequestCommand {
110 case transport.UploadPackService:
111 st, err := d.loader.Load(&url.URL{Path: req.Pathname})
112 if err != nil {
113 _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname))
114 return fmt.Errorf("loading %q: %w", req.Pathname, err)
115 }
116 return transport.UploadPack(ctx, st, r, conn, &transport.UploadPackRequest{
117 GitProtocol: gitProtocol,
118 })
119
120 case transport.UploadArchiveService:
121 st, err := d.loader.Load(&url.URL{Path: req.Pathname})
122 if err != nil {
123 _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname))
124 return fmt.Errorf("loading %q: %w", req.Pathname, err)
125 }
126 return transport.UploadArchive(ctx, st, r, conn, &transport.UploadArchiveRequest{})
127
128 case transport.ReceivePackService:
129 if !d.allowPush {
130 _, _ = pktline.WriteError(conn, fmt.Errorf("push is disabled on this server"))
131 return fmt.Errorf("push rejected for %q", req.Pathname)
132 }
133 st, err := d.loadOrInit(req.Pathname)
134 if err != nil {
135 _, _ = pktline.WriteError(conn, fmt.Errorf("cannot open repository %q", req.Pathname))
136 return fmt.Errorf("opening %q for push: %w", req.Pathname, err)
137 }
138 return transport.ReceivePack(ctx, streamingStorer{Storer: st}, r, conn, &transport.ReceivePackRequest{
139 GitProtocol: gitProtocol,
140 })
141
142 default:
143 _, _ = pktline.WriteError(conn, fmt.Errorf("unsupported service %q", req.RequestCommand))
144 return fmt.Errorf("unsupported service: %s", req.RequestCommand)
145 }
146}
147
148// loadOrInit returns the storer for repoPath, creating an empty bare repository
149// on demand. Git's daemon never auto-creates; objgitd does, so a first push to
150// a new path just works.
151func (d *daemon) loadOrInit(repoPath string) (storage.Storer, error) {
152 st, err := d.loader.Load(&url.URL{Path: repoPath})
153 if err == nil {
154 return st, nil
155 }
156 if !errors.Is(err, transport.ErrRepositoryNotFound) {
157 return nil, err
158 }
159
160 fs, err := d.fs.Chroot(repoPath)
161 if err != nil {
162 return nil, fmt.Errorf("chroot %q: %w", repoPath, err)
163 }
164
165 st = filesystem.NewStorage(fs, cache.NewObjectLRUDefault())
166 if _, err := git.Init(st, git.WithDefaultBranch(plumbing.NewBranchReferenceName("main"))); err != nil {
167 return nil, fmt.Errorf("init bare repo: %w", err)
168 }
169
170 slog.Info("created repository", "path", repoPath)
171 return st, nil
172}