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.7 kB 172 lines
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}