A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 4e8f979415

4e8f9794156fb2ce56a712a4a7a8c923b4d1f4c8

parent: e7586fc527

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T21:03:02Z

Git LFS: batch API, basic transfers, SSH-minted tokens

git-lfs-authenticate joins the git transports in the SSH dispatcher —
usable by git-scoped and deploy keys, download needs read, upload needs
write, invisible repos read as nonexistent — and mints a stateless
HMAC token scoped to one repo and operation (secret persisted in
settings). The HTTP side serves the batch API and basic transfers at
/{owner}/{repo}.git/info/lfs; anonymous downloads work for public
repos, mirroring the smart-http rule; uploads verify size and sha256
before the object lands (temp file + rename).

Storage sits behind lfs.BlobStore — content-addressed local files
under [lfs] root (default <server.root>/lfs, [lfs] max_object_bytes
caps uploads, 512MB default). An S3-compatible backend is a drop-in
implementation with the server proxying; presigned URLs are a later
batch-handler optimization, not a rewrite.

Closes #16
e2e/lfs_test.go added +175
@@ -0,0 +1,175 @@
1package e2e
2
3import (
4 "bytes"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "net"
11 "net/http"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strings"
16 "testing"
17 "time"
18)
19
20func waitForPort(t *testing.T, port int) {
21 t.Helper()
22 deadline := time.Now().Add(10 * time.Second)
23 for {
24 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
25 if err == nil {
26 conn.Close()
27 return
28 }
29 if time.Now().After(deadline) {
30 t.Fatal("listener did not come back")
31 }
32 time.Sleep(50 * time.Millisecond)
33 }
34}
35
36func TestLFS(t *testing.T) {
37 if _, err := exec.LookPath("git-lfs"); err != nil {
38 t.Skip("git-lfs client not installed")
39 }
40 inst := startInstance(t)
41 // LFS hands clients absolute hrefs built from site_url; point it at
42 // the live HTTP listener so the real git-lfs client can follow them.
43 inst.proc.Process.Kill()
44 inst.proc.Wait()
45 raw, err := os.ReadFile(inst.config)
46 if err != nil {
47 t.Fatal(err)
48 }
49 raw = bytes.Replace(raw, []byte(`site_url = "https://gitbay.test"`),
50 []byte(fmt.Sprintf(`site_url = "http://127.0.0.1:%d"`, inst.httpPort)), 1)
51 os.WriteFile(inst.config, raw, 0o600)
52 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
53 inst.proc.Stderr = os.Stderr
54 if err := inst.proc.Start(); err != nil {
55 t.Fatal(err)
56 }
57 waitForPort(t, inst.port)
58
59 aliceKey := inst.newKey(t, "alice")
60 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
61
62 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/big"); code != 0 {
63 t.Fatalf("repo create: %s", errOut)
64 }
65 env := inst.gitEnv(aliceKey)
66 work := t.TempDir()
67 mustGit(t, work, env, "clone", inst.sshURL("alice/big"), "w")
68 dir := filepath.Join(work, "w")
69 mustGit(t, dir, env, "lfs", "install", "--local")
70 mustGit(t, dir, env, "lfs", "track", "*.bin")
71 payload := make([]byte, 1<<20)
72 rand.Read(payload)
73 os.WriteFile(filepath.Join(dir, "data.bin"), payload, 0o644)
74 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
75 mustGit(t, dir, env, "add", ".")
76 mustGit(t, dir, env, "commit", "-q", "-m", "big file")
77 mustGit(t, dir, env, "push", "-q", "origin", "main")
78
79 // The object landed in content-addressed storage, not in git.
80 oid := sha256.Sum256(payload)
81 oidHex := hex.EncodeToString(oid[:])
82 stored := filepath.Join(inst.root, "lfs", oidHex[:2], oidHex[2:4], oidHex)
83 if fi, err := os.Stat(stored); err != nil || fi.Size() != int64(len(payload)) {
84 t.Fatalf("object not in lfs store: %v", err)
85 }
86
87 // A fresh SSH clone round-trips the content through the smudge filter.
88 work2 := t.TempDir()
89 mustGit(t, work2, env, "clone", inst.sshURL("alice/big"), "w")
90 dir2 := filepath.Join(work2, "w")
91 mustGit(t, dir2, env, "lfs", "install", "--local")
92 mustGit(t, dir2, env, "lfs", "pull", "origin")
93 got, err := os.ReadFile(filepath.Join(dir2, "data.bin"))
94 if err != nil || !bytes.Equal(got, payload) {
95 t.Fatalf("ssh round-trip: %v, %d bytes", err, len(got))
96 }
97
98 // Anonymous HTTPS: public repos serve LFS downloads with no credentials.
99 httpURL := fmt.Sprintf("http://127.0.0.1:%d/alice/big.git", inst.httpPort)
100 work3 := t.TempDir()
101 mustGit(t, work3, env, "clone", httpURL, "w")
102 dir3 := filepath.Join(work3, "w")
103 mustGit(t, dir3, env, "lfs", "install", "--local")
104 mustGit(t, dir3, env, "lfs", "pull", "origin")
105 if got, err := os.ReadFile(filepath.Join(dir3, "data.bin")); err != nil || !bytes.Equal(got, payload) {
106 t.Fatalf("anonymous http round-trip: %v, %d bytes", err, len(got))
107 }
108
109 // Anonymous upload is refused; so is anything on a private repo.
110 batch := func(repo, op, auth string) int {
111 body := fmt.Sprintf(`{"operation":%q,"transfers":["basic"],"objects":[{"oid":%q,"size":4}]}`, op, oidHex)
112 req, _ := http.NewRequest("POST",
113 fmt.Sprintf("http://127.0.0.1:%d/alice/%s.git/info/lfs/objects/batch", inst.httpPort, repo),
114 strings.NewReader(body))
115 req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
116 if auth != "" {
117 req.Header.Set("Authorization", auth)
118 }
119 resp, err := http.DefaultClient.Do(req)
120 if err != nil {
121 t.Fatal(err)
122 }
123 resp.Body.Close()
124 return resp.StatusCode
125 }
126 if code := batch("big", "upload", ""); code != 403 {
127 t.Fatalf("anonymous upload: %d", code)
128 }
129 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/vault", "--private"); code != 0 {
130 t.Fatal("private repo create failed")
131 }
132 if code := batch("vault", "download", ""); code != 404 {
133 t.Fatalf("anonymous private batch: %d", code)
134 }
135
136 // Access rules over SSH: a stranger's authenticate on a private repo
137 // reads as nonexistence; upload needs write.
138 bobKey := inst.newKey(t, "bob")
139 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
140 if _, errOut, code := inst.ssh(t, bobKey, "", "git-lfs-authenticate", "alice/vault", "download"); code != 3 || !strings.Contains(errOut, "not found") {
141 t.Fatalf("stranger authenticate: exit %d, %s", code, errOut)
142 }
143 if _, errOut, code := inst.ssh(t, bobKey, "", "git-lfs-authenticate", "alice/big", "upload"); code != 4 || !strings.Contains(errOut, "denied") {
144 t.Fatalf("read-only upload authenticate: exit %d, %s", code, errOut)
145 }
146
147 // A corrupt upload is refused and stores nothing: mint an upload token
148 // via authenticate, then PUT a body that does not match the oid.
149 out, _, code := inst.ssh(t, aliceKey, "", "git-lfs-authenticate", "alice/big", "upload")
150 if code != 0 {
151 t.Fatalf("authenticate: %s", out)
152 }
153 var grant struct {
154 Header map[string]string `json:"header"`
155 }
156 if err := json.Unmarshal([]byte(out), &grant); err != nil {
157 t.Fatalf("authenticate JSON: %v\n%s", err, out)
158 }
159 fakeOID := strings.Repeat("ab", 32)
160 req, _ := http.NewRequest("PUT",
161 fmt.Sprintf("http://127.0.0.1:%d/alice/big.git/info/lfs/objects/%s", inst.httpPort, fakeOID),
162 strings.NewReader("not the content"))
163 req.Header.Set("Authorization", grant.Header["Authorization"])
164 resp, err := http.DefaultClient.Do(req)
165 if err != nil {
166 t.Fatal(err)
167 }
168 resp.Body.Close()
169 if resp.StatusCode != 422 {
170 t.Fatalf("corrupt upload: %d", resp.StatusCode)
171 }
172 if _, err := os.Stat(filepath.Join(inst.root, "lfs", "ab", "ab", fakeOID)); err == nil {
173 t.Fatal("corrupt object was stored")
174 }
175}
internal/config/config.go +9
@@ -22,6 +22,7 @@ type Config struct {
2222 API API `toml:"api"`
2323 Webhooks Webhooks `toml:"webhooks"`
2424 Pages Pages `toml:"pages"`
25 LFS LFS `toml:"lfs"`
2526 Limits Limits `toml:"limits"`
2627 Mail Mail `toml:"mail"`
2728 Mirrors Mirrors `toml:"mirrors"`
@@ -72,6 +73,14 @@ type Registration struct {
7273 Mode string `toml:"mode"` // closed | invite | open
7374 }
7475
76// LFS stores large-file objects content-addressed under Root (default
77// <server.root>/lfs). MaxObjectBytes caps a single object; 0 means the
78// 512MB default.
79type LFS struct {
80 Root string `toml:"root"`
81 MaxObjectBytes int64 `toml:"max_object_bytes"`
82}
83
7584 // Pages serves each public repo's `pages` branch as a static site on
7685 // <owner>.<domain> — a separate origin, so page-authored scripts never run
7786 // on the forge's own host. Empty domain disables the feature.
internal/httpd/lfs.go added +224
@@ -0,0 +1,224 @@
1package httpd
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "gitbay.org/gitbay/internal/lfs"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// Git LFS server: the batch API plus basic-transfer endpoints. SSH clients
17// arrive with a token minted by git-lfs-authenticate; anonymous HTTPS
18// clients may download from public repositories, mirroring the smart-http
19// read-only rule. Uploads always require an upload token.
20
21const lfsMediaType = "application/vnd.git-lfs+json"
22
23func (s *Server) lfsStore() lfs.BlobStore {
24 root := s.cfg.LFS.Root
25 if root == "" {
26 root = filepath.Join(s.cfg.Server.Root, "lfs")
27 }
28 return lfs.LocalStore{Root: root}
29}
30
31func (s *Server) lfsMaxObject() int64 {
32 if s.cfg.LFS.MaxObjectBytes > 0 {
33 return s.cfg.LFS.MaxObjectBytes
34 }
35 return 512 << 20
36}
37
38func (s *Server) lfsSecret() ([]byte, error) {
39 v, err := s.st.LFSSecret(lfs.NewSecret)
40 return []byte(v), err
41}
42
43// lfsAuth resolves what the request may do to the repo: "upload",
44// "download", or "" for no access. Tokens are repo-scoped; without one,
45// public repos allow anonymous download only.
46func (s *Server) lfsAuth(r *http.Request, repo store.Repo) string {
47 auth := r.Header.Get("Authorization")
48 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
49 secret, err := s.lfsSecret()
50 if err != nil {
51 return ""
52 }
53 repoID, op, ok := lfs.Verify(secret, tok, time.Now())
54 if !ok || repoID != repo.ID {
55 return ""
56 }
57 return op
58 }
59 if repo.Visibility == "public" {
60 return "download"
61 }
62 return ""
63}
64
65func lfsError(w http.ResponseWriter, code int, msg string) {
66 w.Header().Set("Content-Type", lfsMediaType)
67 w.WriteHeader(code)
68 json.NewEncoder(w).Encode(map[string]string{"message": msg})
69}
70
71type lfsBatchReq struct {
72 Operation string `json:"operation"`
73 Transfers []string `json:"transfers"`
74 Objects []struct {
75 OID string `json:"oid"`
76 Size int64 `json:"size"`
77 } `json:"objects"`
78}
79
80type lfsAction struct {
81 Href string `json:"href"`
82 Header map[string]string `json:"header,omitempty"`
83 ExpiresIn int `json:"expires_in,omitempty"`
84}
85
86type lfsObject struct {
87 OID string `json:"oid"`
88 Size int64 `json:"size"`
89 Authenticated bool `json:"authenticated,omitempty"`
90 Actions map[string]lfsAction `json:"actions,omitempty"`
91 Error *struct {
92 Code int `json:"code"`
93 Message string `json:"message"`
94 } `json:"error,omitempty"`
95}
96
97// lfsBatch answers POST /{owner}/{repo}/info/lfs/objects/batch.
98func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) {
99 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
100 if err != nil {
101 lfsError(w, http.StatusNotFound, "repository not found")
102 return
103 }
104 granted := s.lfsAuth(r, repo)
105 if granted == "" {
106 // Not naming whether the repo exists, per the enumeration rule.
107 lfsError(w, http.StatusNotFound, "repository not found")
108 return
109 }
110 var req lfsBatchReq
111 if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
112 lfsError(w, http.StatusBadRequest, "bad batch request")
113 return
114 }
115 if req.Operation != "download" && req.Operation != "upload" {
116 lfsError(w, http.StatusBadRequest, "operation must be download or upload")
117 return
118 }
119 if req.Operation == "upload" && granted != "upload" {
120 lfsError(w, http.StatusForbidden, "upload requires write access (authenticate over SSH)")
121 return
122 }
123 if len(req.Objects) > 1000 {
124 lfsError(w, http.StatusUnprocessableEntity, "too many objects in one batch")
125 return
126 }
127
128 // The token in transfer hrefs is operation-scoped and freshly minted,
129 // so anonymous downloads work without the client sending one back.
130 secret, err := s.lfsSecret()
131 if err != nil {
132 lfsError(w, http.StatusInternalServerError, "lfs secret unavailable")
133 return
134 }
135 transferToken := lfs.Sign(secret, repo.ID, req.Operation, time.Now())
136 base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects",
137 strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name)
138 authHeader := map[string]string{"Authorization": "Bearer " + transferToken}
139
140 blobs := s.lfsStore()
141 out := struct {
142 Transfer string `json:"transfer"`
143 Objects []lfsObject `json:"objects"`
144 }{Transfer: "basic"}
145 for _, o := range req.Objects {
146 obj := lfsObject{OID: o.OID, Size: o.Size, Authenticated: true}
147 switch {
148 case !lfs.OIDPat.MatchString(o.OID) || o.Size < 0:
149 obj.Error = &struct {
150 Code int `json:"code"`
151 Message string `json:"message"`
152 }{422, "malformed object"}
153 case req.Operation == "download":
154 if size, ok := blobs.Exists(o.OID); ok {
155 obj.Size = size
156 obj.Actions = map[string]lfsAction{"download": {
157 Href: base + "/" + o.OID, Header: authHeader, ExpiresIn: int(lfs.TokenTTL.Seconds()),
158 }}
159 } else {
160 obj.Error = &struct {
161 Code int `json:"code"`
162 Message string `json:"message"`
163 }{404, "object not found"}
164 }
165 default: // upload
166 if o.Size > s.lfsMaxObject() {
167 obj.Error = &struct {
168 Code int `json:"code"`
169 Message string `json:"message"`
170 }{422, fmt.Sprintf("object exceeds the %d byte limit", s.lfsMaxObject())}
171 } else if _, ok := blobs.Exists(o.OID); !ok {
172 // Present objects get no actions: the client skips them.
173 obj.Actions = map[string]lfsAction{"upload": {
174 Href: base + "/" + o.OID, Header: authHeader, ExpiresIn: int(lfs.TokenTTL.Seconds()),
175 }}
176 }
177 }
178 out.Objects = append(out.Objects, obj)
179 }
180 w.Header().Set("Content-Type", lfsMediaType)
181 json.NewEncoder(w).Encode(out)
182}
183
184// lfsDownload answers GET /{owner}/{repo}/info/lfs/objects/{oid}.
185func (s *Server) lfsDownload(w http.ResponseWriter, r *http.Request) {
186 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
187 if err != nil || s.lfsAuth(r, repo) == "" {
188 lfsError(w, http.StatusNotFound, "not found")
189 return
190 }
191 rc, size, err := s.lfsStore().Get(r.PathValue("oid"))
192 if err != nil {
193 lfsError(w, http.StatusNotFound, "object not found")
194 return
195 }
196 defer rc.Close()
197 w.Header().Set("Content-Type", "application/octet-stream")
198 w.Header().Set("Content-Length", fmt.Sprint(size))
199 w.Header().Set("X-Content-Type-Options", "nosniff")
200 io.Copy(w, rc)
201}
202
203// lfsUpload answers PUT /{owner}/{repo}/info/lfs/objects/{oid}.
204func (s *Server) lfsUpload(w http.ResponseWriter, r *http.Request) {
205 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
206 if err != nil || s.lfsAuth(r, repo) != "upload" {
207 lfsError(w, http.StatusNotFound, "not found")
208 return
209 }
210 oid := r.PathValue("oid")
211 if r.ContentLength < 0 || r.ContentLength > s.lfsMaxObject() {
212 lfsError(w, http.StatusRequestEntityTooLarge, "object too large or length unknown")
213 return
214 }
215 if _, ok := s.lfsStore().Exists(oid); ok {
216 w.WriteHeader(http.StatusOK) // already have it; idempotent
217 return
218 }
219 if err := s.lfsStore().Put(oid, r.Body, r.ContentLength); err != nil {
220 lfsError(w, http.StatusUnprocessableEntity, err.Error())
221 return
222 }
223 w.WriteHeader(http.StatusOK)
224}
internal/httpd/routes.go +5
@@ -27,6 +27,11 @@ func (s *Server) Routes() []Route {
2727 {Method: "GET", Pattern: "/{owner}/{repo}/info/refs", Handler: s.infoRefs},
2828 {Method: "POST", Pattern: "/{owner}/{repo}/git-upload-pack", Handler: s.uploadPack},
2929 {Method: "POST", Pattern: "/{owner}/{repo}/git-receive-pack", Handler: s.receivePackRefusal},
30 // Git LFS: token-authenticated transport (minted over SSH), not
31 // web-session routes — anonymous download for public repos only.
32 {Method: "POST", Pattern: "/{owner}/{repo}/info/lfs/objects/batch", Handler: s.lfsBatch},
33 {Method: "GET", Pattern: "/{owner}/{repo}/info/lfs/objects/{oid}", Handler: s.lfsDownload},
34 {Method: "PUT", Pattern: "/{owner}/{repo}/info/lfs/objects/{oid}", Handler: s.lfsUpload},
3035 }
3136
3237 // Web UI, read-only. These exist in every mode.
internal/httpd/routes_test.go +5 −3
@@ -20,9 +20,11 @@ func TestViewOnlyHasNoMutatingRoutes(t *testing.T) {
2020 if r.Mutating {
2121 t.Errorf("view_only route table contains mutating route %s %s", r.Method, r.Pattern)
2222 }
23 // The only POSTs allowed are the git transport endpoints: a pure
24 // read (upload-pack) and a static refusal (receive-pack).
25 if r.Method != "GET" && !strings.Contains(r.Pattern, "git-upload-pack") && !strings.Contains(r.Pattern, "git-receive-pack") {
23 // The only non-GETs allowed are transport endpoints, which never
24 // authenticate by web session: git upload-pack (a pure read), the
25 // receive-pack static refusal, and LFS (SSH-minted tokens).
26 if r.Method != "GET" && !strings.Contains(r.Pattern, "git-upload-pack") &&
27 !strings.Contains(r.Pattern, "git-receive-pack") && !strings.Contains(r.Pattern, "/info/lfs/") {
2628 t.Errorf("view_only route table contains non-GET route %s %s", r.Method, r.Pattern)
2729 }
2830 for _, word := range []string{"login", "logout", "register", "edit", "new", "settings"} {
internal/lfs/lfs.go added +175
@@ -0,0 +1,175 @@
1// Package lfs implements Git LFS server storage and authorization.
2//
3// The protocol surface lives in httpd (batch API + basic transfers) and
4// sshd (git-lfs-authenticate); this package owns the pieces both need:
5// content-addressed blob storage behind a small interface, and the
6// short-lived tokens that bridge SSH authentication to the HTTP endpoints.
7//
8// BlobStore is deliberately minimal so an S3-compatible backend is a
9// drop-in: implement the four methods against a bucket and the batch and
10// transfer handlers work unchanged (the server streams as a proxy).
11// Handing clients presigned URLs instead is a later optimization to the
12// batch handler, not a rewrite.
13package lfs
14
15import (
16 "crypto/hmac"
17 "crypto/rand"
18 "crypto/sha256"
19 "encoding/base64"
20 "encoding/hex"
21 "fmt"
22 "io"
23 "os"
24 "path/filepath"
25 "regexp"
26 "strconv"
27 "strings"
28 "time"
29)
30
31// OIDPat is a lowercase sha256 hex digest — the only object name LFS uses.
32var OIDPat = regexp.MustCompile(`^[a-f0-9]{64}$`)
33
34// BlobStore holds LFS objects by their sha256 content address.
35type BlobStore interface {
36 // Put stores the reader's content as oid, verifying both size and
37 // digest; a mismatch stores nothing.
38 Put(oid string, r io.Reader, size int64) error
39 Get(oid string) (io.ReadCloser, int64, error)
40 Exists(oid string) (int64, bool)
41 Delete(oid string) error
42}
43
44// LocalStore is the on-disk backend: <root>/<aa>/<bb>/<oid>, written via a
45// temp file and renamed only after the digest checks out.
46type LocalStore struct {
47 Root string
48}
49
50func (s LocalStore) path(oid string) string {
51 return filepath.Join(s.Root, oid[:2], oid[2:4], oid)
52}
53
54func (s LocalStore) Put(oid string, r io.Reader, size int64) error {
55 if !OIDPat.MatchString(oid) {
56 return fmt.Errorf("bad oid %q", oid)
57 }
58 dir := filepath.Dir(s.path(oid))
59 if err := os.MkdirAll(dir, 0o755); err != nil {
60 return err
61 }
62 tmp, err := os.CreateTemp(dir, ".upload-*")
63 if err != nil {
64 return err
65 }
66 defer func() {
67 tmp.Close()
68 os.Remove(tmp.Name())
69 }()
70 h := sha256.New()
71 n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(r, size+1))
72 if err != nil {
73 return err
74 }
75 if n != size {
76 return fmt.Errorf("size mismatch: got %d bytes, expected %d", n, size)
77 }
78 if sum := hex.EncodeToString(h.Sum(nil)); sum != oid {
79 return fmt.Errorf("content digest %s does not match oid", sum[:12])
80 }
81 if err := tmp.Close(); err != nil {
82 return err
83 }
84 return os.Rename(tmp.Name(), s.path(oid))
85}
86
87func (s LocalStore) Get(oid string) (io.ReadCloser, int64, error) {
88 if !OIDPat.MatchString(oid) {
89 return nil, 0, fmt.Errorf("bad oid %q", oid)
90 }
91 f, err := os.Open(s.path(oid))
92 if err != nil {
93 return nil, 0, err
94 }
95 fi, err := f.Stat()
96 if err != nil {
97 f.Close()
98 return nil, 0, err
99 }
100 return f, fi.Size(), nil
101}
102
103func (s LocalStore) Exists(oid string) (int64, bool) {
104 if !OIDPat.MatchString(oid) {
105 return 0, false
106 }
107 fi, err := os.Stat(s.path(oid))
108 if err != nil {
109 return 0, false
110 }
111 return fi.Size(), true
112}
113
114func (s LocalStore) Delete(oid string) error {
115 if !OIDPat.MatchString(oid) {
116 return fmt.Errorf("bad oid %q", oid)
117 }
118 return os.Remove(s.path(oid))
119}
120
121// Tokens bridge SSH authentication to the HTTP endpoints: stateless,
122// HMAC-signed, scoped to one repo and one operation, short-lived. The
123// secret persists in the settings table so tokens survive restarts.
124
125const TokenTTL = time.Hour
126
127// Sign mints a token for op ("download" or "upload") on repoID.
128func Sign(secret []byte, repoID int64, op string, now time.Time) string {
129 payload := fmt.Sprintf("%d:%s:%d", repoID, op, now.Add(TokenTTL).Unix())
130 mac := hmac.New(sha256.New, secret)
131 mac.Write([]byte(payload))
132 return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
133 base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
134}
135
136// Verify checks a token and returns the repo and operation it authorizes.
137func Verify(secret []byte, token string, now time.Time) (repoID int64, op string, ok bool) {
138 payloadB64, macB64, found := strings.Cut(token, ".")
139 if !found {
140 return 0, "", false
141 }
142 payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
143 if err != nil {
144 return 0, "", false
145 }
146 gotMAC, err := base64.RawURLEncoding.DecodeString(macB64)
147 if err != nil {
148 return 0, "", false
149 }
150 mac := hmac.New(sha256.New, secret)
151 mac.Write(payload)
152 if !hmac.Equal(mac.Sum(nil), gotMAC) {
153 return 0, "", false
154 }
155 parts := strings.Split(string(payload), ":")
156 if len(parts) != 3 {
157 return 0, "", false
158 }
159 id, err1 := strconv.ParseInt(parts[0], 10, 64)
160 exp, err2 := strconv.ParseInt(parts[2], 10, 64)
161 if err1 != nil || err2 != nil || now.Unix() > exp {
162 return 0, "", false
163 }
164 if parts[1] != "download" && parts[1] != "upload" {
165 return 0, "", false
166 }
167 return id, parts[1], true
168}
169
170// NewSecret returns 32 random bytes, hex-encoded for the settings table.
171func NewSecret() string {
172 buf := make([]byte, 32)
173 rand.Read(buf)
174 return hex.EncodeToString(buf)
175}
internal/sshd/lfs.go added +78
@@ -0,0 +1,78 @@
1package sshd
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "time"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/lfs"
11 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// runLFSAuthenticate answers the git-lfs client's SSH probe:
17//
18// git-lfs-authenticate <path> download|upload
19//
20// with the HTTP endpoint and a short-lived repo- and operation-scoped
21// token. Access rules mirror the git transports: download needs read,
22// upload needs write; deploy keys authorize by their binding alone, and
23// every denial on an invisible repo reads as nonexistence.
24func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, scope string,
25 argv []string, stdout, stderr io.Writer) int {
26 if len(argv) != 3 || (argv[2] != "download" && argv[2] != "upload") {
27 fmt.Fprintln(stderr, "usage: git-lfs-authenticate <path> download|upload")
28 return protocol.ExitUsage
29 }
30 op := argv[2]
31 write := op == "upload"
32 repo, err := st.RepoByPath(argv[1])
33 if err != nil {
34 fmt.Fprintln(stderr, "repository not found")
35 return protocol.ExitNotFound
36 }
37 if policy.IsDeployScope(scope) {
38 if !policy.DeployScopeAllows(scope, repo.ID, write) {
39 fmt.Fprintln(stderr, "repository not found")
40 return protocol.ExitNotFound
41 }
42 } else {
43 grant, err := st.AccessRole(repo.ID, user.ID)
44 if err != nil {
45 fmt.Fprintln(stderr, "internal error")
46 return protocol.ExitFailure
47 }
48 if !policy.CanRead(user, repo, grant) {
49 fmt.Fprintln(stderr, "repository not found")
50 return protocol.ExitNotFound
51 }
52 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
53 fmt.Fprintf(stderr, "this key's scope (%s) does not allow lfs %s on %s\n", scope, op, repo.Path())
54 return protocol.ExitDenied
55 }
56 if write && !policy.CanWrite(user, repo, grant) {
57 fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
58 return protocol.ExitDenied
59 }
60 }
61 if write && repo.Settings.Archived {
62 fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
63 return protocol.ExitDenied
64 }
65 secret, err := st.LFSSecret(lfs.NewSecret)
66 if err != nil {
67 fmt.Fprintln(stderr, "internal error")
68 return protocol.ExitFailure
69 }
70 token := lfs.Sign([]byte(secret), repo.ID, op, time.Now())
71 json.NewEncoder(stdout).Encode(map[string]any{
72 "href": fmt.Sprintf("%s/%s/%s.git/info/lfs",
73 cfg.Server.SiteURL, repo.OwnerName, repo.Name),
74 "header": map[string]string{"Authorization": "Bearer " + token},
75 "expires_in": int(lfs.TokenTTL.Seconds()),
76 })
77 return protocol.ExitOK
78}
internal/sshd/sshd.go +8
@@ -260,6 +260,14 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cm
260260 return protocol.ExitDenied
261261 }
262262 return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
263 case "git-lfs-authenticate":
264 // Part of the git transport, not the control plane: usable by
265 // git-scoped and deploy keys, with the transports' access rules.
266 if user.Pending {
267 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
268 return protocol.ExitDenied
269 }
270 return runLFSAuthenticate(cfg, st, user, scope, argv, stdout, stderr)
263271 }
264272 }
265273 ctx := &control.Ctx{
internal/store/lfs.go added +28
@@ -0,0 +1,28 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8// LFSSecret returns the instance's LFS token-signing secret, minting and
9// persisting one on first use. gen supplies the new value so this package
10// stays free of crypto choices.
11func (s *Store) LFSSecret(gen func() string) (string, error) {
12 var v string
13 err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'lfs_secret'").Scan(&v)
14 if err == nil {
15 return v, nil
16 }
17 if !errors.Is(err, sql.ErrNoRows) {
18 return "", err
19 }
20 v = gen()
21 // A concurrent first use may win the insert; read back the winner.
22 if _, err := s.DB.Exec(
23 "INSERT INTO settings (key, value) VALUES ('lfs_secret', ?) ON CONFLICT (key) DO NOTHING", v); err != nil {
24 return "", err
25 }
26 err = s.DB.QueryRow("SELECT value FROM settings WHERE key = 'lfs_secret'").Scan(&v)
27 return v, err
28}