Commit 4e8f979415
Verified · cmc ci/build: success
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 | 22 | API API `toml:"api"` |
| 23 | 23 | Webhooks Webhooks `toml:"webhooks"` |
| 24 | 24 | Pages Pages `toml:"pages"` |
| 25 | LFS LFS `toml:"lfs"` | |
| 25 | 26 | Limits Limits `toml:"limits"` |
| 26 | 27 | Mail Mail `toml:"mail"` |
| 27 | 28 | Mirrors Mirrors `toml:"mirrors"` |
| @@ -72,6 +73,14 @@ type Registration struct { | ||
| 72 | 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 | 84 | // Pages serves each public repo's `pages` branch as a static site on |
| 76 | 85 | // <owner>.<domain> — a separate origin, so page-authored scripts never run |
| 77 | 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 | 27 | {Method: "GET", Pattern: "/{owner}/{repo}/info/refs", Handler: s.infoRefs}, |
| 28 | 28 | {Method: "POST", Pattern: "/{owner}/{repo}/git-upload-pack", Handler: s.uploadPack}, |
| 29 | 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 | 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 | 20 | if r.Mutating { |
| 21 | 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 | |
| 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/") { | |
| 26 | 28 | t.Errorf("view_only route table contains non-GET route %s %s", r.Method, r.Pattern) |
| 27 | 29 | } |
| 28 | 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 | 260 | return protocol.ExitDenied |
| 261 | 261 | } |
| 262 | 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 | 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 | } | |