Git backed by object storage because you can't stop me
git object-storage kefka
0

Configure Feed

Select the types of activity you want to include in your feed.

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