Git LFS: batch API, basic transfers, SSH-minted tokens !61
9 files changed, +707 −3
Layout: unified · split
e2e/lfs_test.go added +175
| @@ -0,0 +1,175 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 20 | func 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 | |||
| 36 | func 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 { | |||
| 22 | API API `toml:"api"` | 22 | API API `toml:"api"` |
| 23 | Webhooks Webhooks `toml:"webhooks"` | 23 | Webhooks Webhooks `toml:"webhooks"` |
| 24 | Pages Pages `toml:"pages"` | 24 | Pages Pages `toml:"pages"` |
| 25 | LFS LFS `toml:"lfs"` | ||
| 25 | Limits Limits `toml:"limits"` | 26 | Limits Limits `toml:"limits"` |
| 26 | Mail Mail `toml:"mail"` | 27 | Mail Mail `toml:"mail"` |
| 27 | Mirrors Mirrors `toml:"mirrors"` | 28 | Mirrors Mirrors `toml:"mirrors"` |
| @@ -72,6 +73,14 @@ type Registration struct { | |||
| 72 | Mode string `toml:"mode"` // closed | invite | open | 73 | Mode string `toml:"mode"` // closed | invite | open |
| 73 | } | 74 | } |
| 74 | 75 | ||
| 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. | ||
| 79 | type LFS struct { | ||
| 80 | Root string `toml:"root"` | ||
| 81 | MaxObjectBytes int64 `toml:"max_object_bytes"` | ||
| 82 | } | ||
| 83 | |||
| 75 | // Pages serves each public repo's `pages` branch as a static site on | 84 | // Pages serves each public repo's `pages` branch as a static site on |
| 76 | // <owner>.<domain> — a separate origin, so page-authored scripts never run | 85 | // <owner>.<domain> — a separate origin, so page-authored scripts never run |
| 77 | // on the forge's own host. Empty domain disables the feature. | 86 | // on the forge's own host. Empty domain disables the feature. |
internal/httpd/lfs.go added +224
| @@ -0,0 +1,224 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 21 | const lfsMediaType = "application/vnd.git-lfs+json" | ||
| 22 | |||
| 23 | func (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 | |||
| 31 | func (s *Server) lfsMaxObject() int64 { | ||
| 32 | if s.cfg.LFS.MaxObjectBytes > 0 { | ||
| 33 | return s.cfg.LFS.MaxObjectBytes | ||
| 34 | } | ||
| 35 | return 512 << 20 | ||
| 36 | } | ||
| 37 | |||
| 38 | func (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. | ||
| 46 | func (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 | |||
| 65 | func 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 | |||
| 71 | type 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 | |||
| 80 | type lfsAction struct { | ||
| 81 | Href string `json:"href"` | ||
| 82 | Header map[string]string `json:"header,omitempty"` | ||
| 83 | ExpiresIn int `json:"expires_in,omitempty"` | ||
| 84 | } | ||
| 85 | |||
| 86 | type 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. | ||
| 98 | func (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}. | ||
| 185 | func (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}. | ||
| 204 | func (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 { | |||
| 27 | {Method: "GET", Pattern: "/{owner}/{repo}/info/refs", Handler: s.infoRefs}, | 27 | {Method: "GET", Pattern: "/{owner}/{repo}/info/refs", Handler: s.infoRefs}, |
| 28 | {Method: "POST", Pattern: "/{owner}/{repo}/git-upload-pack", Handler: s.uploadPack}, | 28 | {Method: "POST", Pattern: "/{owner}/{repo}/git-upload-pack", Handler: s.uploadPack}, |
| 29 | {Method: "POST", Pattern: "/{owner}/{repo}/git-receive-pack", Handler: s.receivePackRefusal}, | 29 | {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}, | ||
| 30 | } | 35 | } |
| 31 | 36 | ||
| 32 | // Web UI, read-only. These exist in every mode. | 37 | // 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) { | |||
| 20 | if r.Mutating { | 20 | if r.Mutating { |
| 21 | t.Errorf("view_only route table contains mutating route %s %s", r.Method, r.Pattern) | 21 | t.Errorf("view_only route table contains mutating route %s %s", r.Method, r.Pattern) |
| 22 | } | 22 | } |
| 23 | // The only POSTs allowed are the git transport endpoints: a pure | 23 | // The only non-GETs allowed are transport endpoints, which never |
| 24 | // read (upload-pack) and a static refusal (receive-pack). | 24 | // authenticate by web session: git upload-pack (a pure read), the |
| 25 | if r.Method != "GET" && !strings.Contains(r.Pattern, "git-upload-pack") && !strings.Contains(r.Pattern, "git-receive-pack") { | 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/") { | ||
| 26 | t.Errorf("view_only route table contains non-GET route %s %s", r.Method, r.Pattern) | 28 | t.Errorf("view_only route table contains non-GET route %s %s", r.Method, r.Pattern) |
| 27 | } | 29 | } |
| 28 | for _, word := range []string{"login", "logout", "register", "edit", "new", "settings"} { | 30 | 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. | ||
| 13 | package lfs | ||
| 14 | |||
| 15 | import ( | ||
| 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. | ||
| 32 | var OIDPat = regexp.MustCompile(`^[a-f0-9]{64}$`) | ||
| 33 | |||
| 34 | // BlobStore holds LFS objects by their sha256 content address. | ||
| 35 | type 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. | ||
| 46 | type LocalStore struct { | ||
| 47 | Root string | ||
| 48 | } | ||
| 49 | |||
| 50 | func (s LocalStore) path(oid string) string { | ||
| 51 | return filepath.Join(s.Root, oid[:2], oid[2:4], oid) | ||
| 52 | } | ||
| 53 | |||
| 54 | func (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 | |||
| 87 | func (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 | |||
| 103 | func (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 | |||
| 114 | func (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 | |||
| 125 | const TokenTTL = time.Hour | ||
| 126 | |||
| 127 | // Sign mints a token for op ("download" or "upload") on repoID. | ||
| 128 | func 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. | ||
| 137 | func 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. | ||
| 171 | func 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 @@ | |||
| 1 | package sshd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 24 | func 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 | |||
| 260 | return protocol.ExitDenied | 260 | return protocol.ExitDenied |
| 261 | } | 261 | } |
| 262 | return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) | 262 | 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) | ||
| 263 | } | 271 | } |
| 264 | } | 272 | } |
| 265 | ctx := &control.Ctx{ | 273 | ctx := &control.Ctx{ |
internal/store/lfs.go added +28
| @@ -0,0 +1,28 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 11 | func (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 | } | ||