Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 1: a working git remote with access control

653e0cfc227c992e5b26ea10057aa0b424b6ce4f · Keenan Nemetz · 18d ago · parent c5626f1b

Phase 1: a working git remote with access control

Vole is now a functioning forge foundation: one CGO-free binary that serves
real git over smart HTTP and enforces per-repo policy in pre-receive.

What is in:

- internal/transport drives `git http-backend` as a CGI subprocess. Chunked
  uploads are spooled to learn CONTENT_LENGTH (git's default client config
  produces them), spool files are cleaned on error and abort, and a janitor
  sweeps anything a crash left behind. Pushes are capped by size and
  serialised behind a semaphore, because Phase 0 measured 1.13 GB of git RSS
  for a single 1 GiB push.
- internal/acl stores roles on refs/vole/acl inside each repository, so
  access rules travel with a clone. One evaluation function is shared by the
  hook and the web layer.
- internal/hook enforces policy in pre-receive: authentication, write role,
  protected refs, per-blob size limits, and a refusal to accept pushes to
  refs/vole/* - without that last check a writer could push their own
  acl.json and grant themselves admin.
- internal/auth holds users, personal access tokens and web sessions in
  SQLite. Passwords use PBKDF2-SHA256; tokens are 256-bit random values and
  are stored as SHA-256 hashes, since there is no low-entropy secret to grind.
- internal/index is the derived read model, rebuildable from disk and refs.
- internal/repo owns the bare-repo store and the metadata ref CAS helper.
- internal/web is the browse surface: repo list, tree, blob, raw, history,
  login, and per-repo settings for access rules, rename and delete.
- internal/signing creates the instance ed25519 key at first boot for the
  Phase 7 work.

Every Phase 1 exit criterion has a test that would fail if it regressed:
clone/push against a real listener, read-only and anonymous rejection with the
ref proven unmoved, rebuild-from-refs compared against the actual acl ref, a
spool file proven absent after an aborted chunked upload, and two pushes
completing through a single-slot semaphore. scripts/ci.sh runs gofmt, vet,
build, a markdown link check and the race-enabled suite.

Verified by hand as well: a clean `vole init` -> user -> token -> push ->
clone cycle, web login and browse, and a 256 MiB chunked push killed
mid-flight leaving no ref, no spool file and no orphaned git processes.

internal/repo/repo.go +298

@@ -0,0 +1,298 @@
1 // Package repo owns the on-disk bare-repo store and the metadata refs that live
2 // inside each repository.
3 package repo
4
5 import (
6 "crypto/rand"
7 "encoding/hex"
8 "fmt"
9 "os"
10 "path/filepath"
11 "regexp"
12 "sort"
13 "strings"
14 "time"
15
16 "github.com/rodent-software/vole/internal/acl"
17 "github.com/rodent-software/vole/internal/gitx"
18 )
19
20 const (
21 ACLRef = "refs/vole/acl"
22 ACLPath = "acl.json"
23 idFile = "vole-id"
24 descFile = "description"
25 maxCASRetry = 32
26 )
27
28 var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
29
30 // ValidateName enforces a repo naming scheme that is safe as a directory name
31 // and as a URL path segment.
32 func ValidateName(name string) error {
33 if !nameRE.MatchString(name) {
34 return fmt.Errorf("invalid repo name %q: use lowercase letters, digits, dot, dash or underscore, starting with a letter or digit (max 63)", name)
35 }
36 if strings.HasSuffix(name, ".git") {
37 return fmt.Errorf("invalid repo name %q: drop the .git suffix", name)
38 }
39 if strings.Contains(name, "..") {
40 return fmt.Errorf("invalid repo name %q", name)
41 }
42 return nil
43 }
44
45 type Store struct {
46 Root string
47 BinPath string
48 }
49
50 func New(root, binPath string) *Store { return &Store{Root: root, BinPath: binPath} }
51
52 // Meta is a repository's identity plus the facts the web layer needs.
53 type Meta struct {
54 ID string
55 Name string
56 Dir string
57 Created time.Time
58 Description string
59 DefaultBranch string
60 }
61
62 func (s *Store) path(name string) string { return filepath.Join(s.Root, name+".git") }
63
64 // Create initialises a bare repo, stamps it with a stable id and seeds its ACL
65 // so the creator is admin.
66 func (s *Store) Create(name, owner, description string) (*Meta, error) {
67 if err := ValidateName(name); err != nil {
68 return nil, err
69 }
70 dir := s.path(name)
71 if _, err := os.Stat(dir); err == nil {
72 return nil, fmt.Errorf("repo %q already exists", name)
73 }
74 if err := gitx.InitBare(dir, "main"); err != nil {
75 return nil, err
76 }
77 id, err := newID()
78 if err != nil {
79 return nil, err
80 }
81 if err := os.WriteFile(filepath.Join(dir, idFile), []byte(id+"\n"), 0o644); err != nil {
82 return nil, err
83 }
84 if description != "" {
85 if err := os.WriteFile(filepath.Join(dir, descFile), []byte(description+"\n"), 0o644); err != nil {
86 return nil, err
87 }
88 }
89 m := &Meta{ID: id, Name: name, Dir: dir, Created: time.Now().UTC(), Description: description, DefaultBranch: "main"}
90 if err := m.SetACL(acl.Empty(owner), owner); err != nil {
91 return nil, err
92 }
93 if err := s.InstallHooks(m.Dir); err != nil {
94 return nil, err
95 }
96 return m, nil
97 }
98
99 // Open loads a repo by name, returning an error if it is not a Vole repo.
100 func (s *Store) Open(name string) (*Meta, error) {
101 if err := ValidateName(name); err != nil {
102 return nil, err
103 }
104 dir := s.path(name)
105 st, err := os.Stat(dir)
106 if err != nil || !st.IsDir() {
107 return nil, os.ErrNotExist
108 }
109 idb, err := os.ReadFile(filepath.Join(dir, idFile))
110 if err != nil {
111 return nil, fmt.Errorf("repo %q is not a Vole repository (no %s)", name, idFile)
112 }
113 m := &Meta{ID: strings.TrimSpace(string(idb)), Name: name, Dir: dir, DefaultBranch: "main"}
114 if st, err := os.Stat(dir); err == nil {
115 m.Created = st.ModTime().UTC()
116 }
117 if b, err := os.ReadFile(filepath.Join(dir, descFile)); err == nil {
118 m.Description = strings.TrimSpace(string(b))
119 }
120 if br := gitx.Open(dir).Symref(); br != "" {
121 m.DefaultBranch = br
122 }
123 return m, nil
124 }
125
126 // List returns every repo in the store, sorted by name.
127 func (s *Store) List() ([]*Meta, error) {
128 ents, err := os.ReadDir(s.Root)
129 if err != nil {
130 if os.IsNotExist(err) {
131 return nil, nil
132 }
133 return nil, err
134 }
135 var out []*Meta
136 for _, e := range ents {
137 if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
138 continue
139 }
140 name := strings.TrimSuffix(e.Name(), ".git")
141 m, err := s.Open(name)
142 if err != nil {
143 continue
144 }
145 out = append(out, m)
146 }
147 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
148 return out, nil
149 }
150
151 func (s *Store) Rename(oldName, newName string) error {
152 if err := ValidateName(newName); err != nil {
153 return err
154 }
155 oldDir, newDir := s.path(oldName), s.path(newName)
156 if _, err := os.Stat(newDir); err == nil {
157 return fmt.Errorf("repo %q already exists", newName)
158 }
159 if _, err := os.Stat(oldDir); err != nil {
160 return fmt.Errorf("repo %q not found", oldName)
161 }
162 if err := os.Rename(oldDir, newDir); err != nil {
163 return err
164 }
165 // The clone URL changed, so the hooks must still point at a valid binary.
166 return s.InstallHooks(newDir)
167 }
168
169 func (s *Store) Delete(name string) error {
170 dir := s.path(name)
171 if _, err := os.Stat(dir); err != nil {
172 return fmt.Errorf("repo %q not found", name)
173 }
174 return os.RemoveAll(dir)
175 }
176
177 func (m *Meta) Git() *gitx.Repo { return gitx.Open(m.Dir) }
178
179 // ACL reads the repo's access rules from refs/vole/acl. A repo with no ACL ref
180 // gets a deny-by-default ACL with no principals.
181 func (m *Meta) ACL() (*acl.ACL, error) {
182 g := m.Git()
183 commit, err := g.RevParse(ACLRef)
184 if err != nil {
185 return nil, err
186 }
187 if commit == "" {
188 return acl.Empty(""), nil
189 }
190 data, err := g.BlobAt(commit, ACLPath)
191 if err != nil {
192 return nil, fmt.Errorf("read acl: %w", err)
193 }
194 return acl.Parse(data)
195 }
196
197 // SetACL writes the ACL as a commit on refs/vole/acl with a compare-and-swap ref
198 // update, so two concurrent edits cannot silently lose one another.
199 func (m *Meta) SetACL(a *acl.ACL, actor string) error {
200 payload, err := a.Bytes()
201 if err != nil {
202 return err
203 }
204 g := m.Git()
205 blob, err := g.HashObject(payload)
206 if err != nil {
207 return err
208 }
209 var lastErr error
210 for attempt := 0; attempt < maxCASRetry; attempt++ {
211 old, err := g.RevParse(ACLRef)
212 if err != nil {
213 return err
214 }
215 var entries []gitx.Entry
216 if old != "" {
217 entries, err = g.LsTree(old)
218 if err != nil {
219 return err
220 }
221 }
222 entries = upsert(entries, gitx.Entry{Mode: "100644", Type: "blob", SHA: blob, Name: ACLPath})
223 tree, err := g.MkTree(entries)
224 if err != nil {
225 return err
226 }
227 commit, err := g.CommitTree(tree, old, fmt.Sprintf("acl: update by %s", actorOr(actor)))
228 if err != nil {
229 return err
230 }
231 ok, err := g.UpdateRefCAS(ACLRef, commit, old)
232 if err != nil {
233 return err
234 }
235 if ok {
236 return nil
237 }
238 lastErr = fmt.Errorf("acl: lost %d ref races", attempt+1)
239 }
240 return lastErr
241 }
242
243 // InstallHooks writes the server-side hook that enforces policy. It is
244 // regenerated on serve start and after rename so the binary path stays correct.
245 func (s *Store) InstallHooks(dir string) error {
246 hookDir := filepath.Join(dir, "hooks")
247 if err := os.MkdirAll(hookDir, 0o755); err != nil {
248 return err
249 }
250 body := "#!/bin/sh\n# Generated by Vole. Do not edit: rewritten on startup.\nexec " +
251 shellQuote(s.BinPath) + " hook pre-receive\n"
252 p := filepath.Join(hookDir, "pre-receive")
253 return os.WriteFile(p, []byte(body), 0o755)
254 }
255
256 func (s *Store) InstallAllHooks() (int, error) {
257 repos, err := s.List()
258 if err != nil {
259 return 0, err
260 }
261 n := 0
262 for _, m := range repos {
263 if err := s.InstallHooks(m.Dir); err != nil {
264 return n, err
265 }
266 n++
267 }
268 return n, nil
269 }
270
271 func shellQuote(s string) string {
272 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
273 }
274
275 func upsert(es []gitx.Entry, add gitx.Entry) []gitx.Entry {
276 for i := range es {
277 if es[i].Name == add.Name {
278 es[i] = add
279 return es
280 }
281 }
282 return append(es, add)
283 }
284
285 func actorOr(a string) string {
286 if strings.TrimSpace(a) == "" {
287 return "unknown"
288 }
289 return a
290 }
291
292 func newID() (string, error) {
293 b := make([]byte, 12)
294 if _, err := rand.Read(b); err != nil {
295 return "", err
296 }
297 return hex.EncodeToString(b), nil
298 }