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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0: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/web/web.go +647

@@ -0,0 +1,647 @@
1 // Package web is Vole's HTML surface: repo list, browse, settings and login.
2 // Read paths all go through git, never through the derived index, so what you
3 // see is what is actually committed.
4 package web
5
6 import (
7 "fmt"
8 "html/template"
9 "net/http"
10 "net/url"
11 "strings"
12 "time"
13 "unicode/utf8"
14
15 "github.com/rodent-software/vole/internal/acl"
16 "github.com/rodent-software/vole/internal/auth"
17 "github.com/rodent-software/vole/internal/config"
18 "github.com/rodent-software/vole/internal/gitx"
19 "github.com/rodent-software/vole/internal/index"
20 "github.com/rodent-software/vole/internal/repo"
21 )
22
23 const SessionCookie = "vole_session"
24
25 type Web struct {
26 Repos *repo.Store
27 Auth *auth.Store
28 Index *index.Store
29 Cfg *config.Config
30 Tmpl *template.Template
31 Logger func(format string, args ...any)
32 }
33
34 func New(repos *repo.Store, a *auth.Store, ix *index.Store, cfg *config.Config) (*Web, error) {
35 t, err := template.New("vole").Funcs(template.FuncMap{
36 "since": humanSince,
37 "short": shortSHA,
38 "esc": url.PathEscape,
39 "join": func(v []string) string { return strings.Join(v, "\n") },
40 "list": func(v ...string) []string { return v },
41 }).Parse(templates)
42 if err != nil {
43 return nil, err
44 }
45 w := &Web{Repos: repos, Auth: a, Index: ix, Cfg: cfg, Tmpl: t}
46 w.Logger = func(string, ...any) {}
47 return w, nil
48 }
49
50 // ---------- request context helpers ----------
51
52 func (w *Web) user(r *http.Request) *auth.User {
53 c, err := r.Cookie(SessionCookie)
54 if err != nil {
55 return nil
56 }
57 u, err := w.Auth.SessionUser(c.Value)
58 if err != nil {
59 return nil
60 }
61 return u
62 }
63
64 func (w *Web) IsInstanceAdmin(u *auth.User) bool {
65 if u == nil {
66 return false
67 }
68 if w.Cfg.IsAdmin(u.Username) {
69 return true
70 }
71 // Bootstrap rule: on a single-user instance that user is the admin, so a
72 // fresh install is usable without hand-editing config.
73 if n, err := w.Auth.CountUsers(); err == nil && n == 1 {
74 return true
75 }
76 return false
77 }
78
79 func (w *Web) roleFor(m *repo.Meta, u *auth.User) acl.Role {
80 a, err := m.ACL()
81 if err != nil {
82 return acl.None
83 }
84 if u == nil {
85 return a.RoleFor("")
86 }
87 return a.RoleFor(u.Username)
88 }
89
90 // Page is exported because html/template cannot evaluate promoted fields
91 // through an unexported embedded type.
92 type Page struct {
93 Title string
94 User *auth.User
95 Admin bool
96 Flash string
97 Error string
98 }
99
100 func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) {
101 rw.Header().Set("Content-Type", "text/html; charset=utf-8")
102 if status != 0 {
103 rw.WriteHeader(status)
104 }
105 if err := w.Tmpl.ExecuteTemplate(rw, name, data); err != nil {
106 w.Logger("render %s: %v", name, err)
107 }
108 }
109
110 func (w *Web) base(r *http.Request, title string) Page {
111 u := w.user(r)
112 p := Page{Title: title, User: u, Admin: w.IsInstanceAdmin(u)}
113 if f := r.URL.Query().Get("ok"); f != "" {
114 p.Flash = f
115 }
116 if e := r.URL.Query().Get("err"); e != "" {
117 p.Error = e
118 }
119 return p
120 }
121
122 // requireUser redirects to the login page when unauthenticated.
123 func (w *Web) requireUser(rw http.ResponseWriter, r *http.Request) *auth.User {
124 u := w.user(r)
125 if u == nil {
126 next := url.QueryEscape(r.URL.RequestURI())
127 http.Redirect(rw, r, "/login?next="+next, http.StatusSeeOther)
128 return nil
129 }
130 return u
131 }
132
133 // ---------- handlers ----------
134
135 func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) {
136 u := w.requireUser(rw, r)
137 if u == nil {
138 return
139 }
140 repos, err := w.Index.List()
141 if err != nil {
142 http.Error(rw, "index unavailable: "+err.Error(), 500)
143 return
144 }
145 // Only show repos the user can read.
146 var visible []index.Entry
147 for _, e := range repos {
148 m, err := w.Repos.Open(e.Name)
149 if err != nil {
150 continue
151 }
152 if w.roleFor(m, u).AtLeast(acl.Read) {
153 visible = append(visible, e)
154 }
155 }
156 w.render(rw, "index", struct {
157 Page
158 Repos []index.Entry
159 }{w.base(r, "Repositories"), visible}, 0)
160 }
161
162 func (w *Web) NewRepo(rw http.ResponseWriter, r *http.Request) {
163 u := w.requireUser(rw, r)
164 if u == nil {
165 return
166 }
167 if !w.IsInstanceAdmin(u) {
168 http.Error(rw, "only instance admins can create repositories", http.StatusForbidden)
169 return
170 }
171 name := strings.TrimSpace(r.FormValue("name"))
172 desc := strings.TrimSpace(r.FormValue("description"))
173 m, err := w.Repos.Create(name, u.Username, desc)
174 if err != nil {
175 http.Redirect(rw, r, "/?err="+url.QueryEscape(err.Error()), http.StatusSeeOther)
176 return
177 }
178 if err := w.Index.Upsert(m); err != nil {
179 w.Logger("index upsert %s: %v", name, err)
180 }
181 http.Redirect(rw, r, "/r/"+url.PathEscape(m.Name)+"?ok="+url.QueryEscape("repository created"), http.StatusSeeOther)
182 }
183
184 func (w *Web) repo(rw http.ResponseWriter, r *http.Request) (*repo.Meta, *auth.User, acl.Role, bool) {
185 u := w.requireUser(rw, r)
186 if u == nil {
187 return nil, nil, acl.None, false
188 }
189 name := r.PathValue("name")
190 m, err := w.Repos.Open(name)
191 if err != nil {
192 http.NotFound(rw, r)
193 return nil, nil, acl.None, false
194 }
195 role := w.roleFor(m, u)
196 if !role.AtLeast(acl.Read) {
197 http.Error(rw, fmt.Sprintf("you do not have read access to %s", name), http.StatusForbidden)
198 return nil, nil, acl.None, false
199 }
200 return m, u, role, true
201 }
202
203 func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) {
204 m, _, role, ok := w.repo(rw, r)
205 if !ok {
206 return
207 }
208 g := m.Git()
209 ref := r.URL.Query().Get("ref")
210 if ref == "" {
211 ref = m.DefaultBranch
212 }
213 commit, _ := g.RevParse(ref)
214 var entries []gitx.Entry
215 var commits []gitx.Commit
216 count := 0
217 if commit != "" {
218 entries, _ = g.LsTree(commit)
219 commits, _ = g.Log(commit, 10)
220 count, _ = g.CountCommits(commit)
221 }
222 branches, _ := g.ListRefs("refs/heads/")
223 names := make([]string, 0, len(branches))
224 for k := range branches {
225 names = append(names, strings.TrimPrefix(k, "refs/heads/"))
226 }
227 w.render(rw, "repo", struct {
228 Page
229 Name string
230 Description string
231 Ref string
232 Role acl.Role
233 RepoAdmin bool
234 CloneURL string
235 Entries []gitx.Entry
236 Commits []gitx.Commit
237 Commit string
238 Count int
239 Branches []string
240 }{
241 Page: w.base(r, m.Name),
242 Name: m.Name,
243 Description: m.Description,
244 Ref: ref,
245 Role: role,
246 RepoAdmin: role.AtLeast(acl.Admin),
247 CloneURL: cloneURL(r, m.Name),
248 Entries: entries,
249 Commits: commits,
250 Commit: commit,
251 Count: count,
252 Branches: names,
253 }, 0)
254 }
255
256 func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) {
257 m, _, _, ok := w.repo(rw, r)
258 if !ok {
259 return
260 }
261 ref, p := r.PathValue("ref"), r.PathValue("path")
262 if !safePath(p) {
263 http.Error(rw, "invalid path", http.StatusBadRequest)
264 return
265 }
266 g := m.Git()
267 commit, _ := g.RevParse(ref)
268 if commit == "" {
269 http.NotFound(rw, r)
270 return
271 }
272 treeish := commit
273 if p != "" {
274 treeish = commit + ":" + p
275 }
276 entries, err := g.LsTree(treeish)
277 if err != nil {
278 http.Error(rw, "not a directory: "+err.Error(), http.StatusBadRequest)
279 return
280 }
281 w.render(rw, "tree", struct {
282 Page
283 Name string
284 Ref string
285 Path string
286 Entries []gitx.Entry
287 Crumbs []crumb
288 }{w.base(r, m.Name+" · "+ref), m.Name, ref, p, entries, crumbs(ref, p)}, 0)
289 }
290
291 func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) {
292 w.blob(rw, r, false)
293 }
294
295 func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) {
296 w.blob(rw, r, true)
297 }
298
299 const maxInlineBytes = 1 << 20
300
301 func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) {
302 m, _, _, ok := w.repo(rw, r)
303 if !ok {
304 return
305 }
306 ref, p := r.PathValue("ref"), r.PathValue("path")
307 if !safePath(p) {
308 http.Error(rw, "invalid path", http.StatusBadRequest)
309 return
310 }
311 g := m.Git()
312 commit, _ := g.RevParse(ref)
313 if commit == "" {
314 http.NotFound(rw, r)
315 return
316 }
317 data, err := g.BlobAt(commit, p)
318 if err != nil {
319 http.NotFound(rw, r)
320 return
321 }
322 if raw {
323 rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
324 rw.Write(data)
325 return
326 }
327 content := string(data)
328 binary := !utf8.Valid(data)
329 truncated := false
330 if len(data) > maxInlineBytes {
331 content = string(data[:maxInlineBytes])
332 truncated = true
333 }
334 w.render(rw, "blob", struct {
335 Page
336 Name string
337 Ref string
338 Path string
339 Content string
340 Binary bool
341 Truncated bool
342 Size int64
343 Crumbs []crumb
344 }{w.base(r, m.Name+" · "+p), m.Name, ref, p, content, binary, truncated, int64(len(data)), crumbs(ref, p)}, 0)
345 }
346
347 func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) {
348 m, _, _, ok := w.repo(rw, r)
349 if !ok {
350 return
351 }
352 ref := r.PathValue("ref")
353 g := m.Git()
354 commit, _ := g.RevParse(ref)
355 if commit == "" {
356 http.NotFound(rw, r)
357 return
358 }
359 commits, err := g.Log(commit, 100)
360 if err != nil {
361 http.Error(rw, err.Error(), 500)
362 return
363 }
364 w.render(rw, "commits", struct {
365 Page
366 Name string
367 Ref string
368 Commits []gitx.Commit
369 }{w.base(r, m.Name+" · history"), m.Name, ref, commits}, 0)
370 }
371
372 func (w *Web) Settings(rw http.ResponseWriter, r *http.Request) {
373 m, _, role, ok := w.repo(rw, r)
374 if !ok {
375 return
376 }
377 if !role.AtLeast(acl.Admin) {
378 http.Error(rw, "repository settings require admin access", http.StatusForbidden)
379 return
380 }
381 a, err := m.ACL()
382 if err != nil {
383 http.Error(rw, err.Error(), 500)
384 return
385 }
386 var all []string
387 if users, err := w.Auth.ListUsers(); err == nil {
388 for _, x := range users {
389 all = append(all, x.Username)
390 }
391 }
392 w.render(rw, "settings", struct {
393 Page
394 Name string
395 ACL *acl.ACL
396 Principals []string
397 Users []string
398 }{
399 Page: w.base(r, m.Name+" · settings"),
400 Name: m.Name,
401 ACL: a,
402 Principals: a.Principals(),
403 Users: all,
404 }, 0)
405 }
406
407 func (w *Web) UpdateACL(rw http.ResponseWriter, r *http.Request) {
408 m, u, role, ok := w.repo(rw, r)
409 if !ok {
410 return
411 }
412 if !role.AtLeast(acl.Admin) {
413 http.Error(rw, "repository settings require admin access", http.StatusForbidden)
414 return
415 }
416 if err := r.ParseForm(); err != nil {
417 http.Error(rw, err.Error(), 400)
418 return
419 }
420 a, err := m.ACL()
421 if err != nil {
422 http.Error(rw, err.Error(), 500)
423 return
424 }
425 if d := r.FormValue("default"); d != "" {
426 if _, err := acl.ParseRole(d); err != nil {
427 http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther)
428 return
429 }
430 a.Default = strings.ToLower(d)
431 }
432 for k, vals := range r.Form {
433 if !strings.HasPrefix(k, "role_") || len(vals) == 0 {
434 continue
435 }
436 principal := strings.TrimPrefix(k, "role_")
437 if err := a.SetEntry(principal, vals[0]); err != nil {
438 http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther)
439 return
440 }
441 }
442 if prot := r.FormValue("protected"); prot != "" || r.Form.Has("protected") {
443 var lines []string
444 for _, line := range strings.Split(prot, "\n") {
445 if l := strings.TrimSpace(line); l != "" {
446 lines = append(lines, l)
447 }
448 }
449 a.Protected = lines
450 }
451 if err := m.SetACL(a, u.Username); err != nil {
452 http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther)
453 return
454 }
455 if err := w.Index.Upsert(m); err != nil {
456 w.Logger("index upsert: %v", err)
457 }
458 http.Redirect(rw, r, settingsURL(m.Name, ""), http.StatusSeeOther)
459 }
460
461 func (w *Web) RenameRepo(rw http.ResponseWriter, r *http.Request) {
462 m, u, role, ok := w.repo(rw, r)
463 if !ok {
464 return
465 }
466 if !role.AtLeast(acl.Admin) || !w.IsInstanceAdmin(u) {
467 http.Error(rw, "renaming requires repository admin and instance admin", http.StatusForbidden)
468 return
469 }
470 newName := strings.TrimSpace(r.FormValue("name"))
471 oldID := m.ID
472 if err := w.Repos.Rename(m.Name, newName); err != nil {
473 http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther)
474 return
475 }
476 _ = w.Index.Remove(oldID)
477 if nm, err := w.Repos.Open(newName); err == nil {
478 _ = w.Index.Upsert(nm)
479 }
480 http.Redirect(rw, r, "/r/"+url.PathEscape(newName)+"?ok="+url.QueryEscape("repository renamed"), http.StatusSeeOther)
481 }
482
483 func (w *Web) DeleteRepo(rw http.ResponseWriter, r *http.Request) {
484 m, u, role, ok := w.repo(rw, r)
485 if !ok {
486 return
487 }
488 if !role.AtLeast(acl.Admin) || !w.IsInstanceAdmin(u) {
489 http.Error(rw, "deleting requires repository admin and instance admin", http.StatusForbidden)
490 return
491 }
492 if r.FormValue("confirm") != m.Name {
493 http.Redirect(rw, r, settingsURL(m.Name, "type the repository name to confirm deletion"), http.StatusSeeOther)
494 return
495 }
496 if err := w.Repos.Delete(m.Name); err != nil {
497 http.Redirect(rw, r, settingsURL(m.Name, err.Error()), http.StatusSeeOther)
498 return
499 }
500 _ = w.Index.Remove(m.ID)
501 http.Redirect(rw, r, "/?ok="+url.QueryEscape("repository deleted"), http.StatusSeeOther)
502 }
503
504 // ---------- login ----------
505
506 func (w *Web) LoginPage(rw http.ResponseWriter, r *http.Request) {
507 if w.user(r) != nil {
508 http.Redirect(rw, r, "/", http.StatusSeeOther)
509 return
510 }
511 p := w.base(r, "Sign in")
512 w.render(rw, "login", struct {
513 Page
514 Next string
515 }{p, r.URL.Query().Get("next")}, 0)
516 }
517
518 func (w *Web) Login(rw http.ResponseWriter, r *http.Request) {
519 if err := r.ParseForm(); err != nil {
520 http.Error(rw, err.Error(), 400)
521 return
522 }
523 username := strings.TrimSpace(r.FormValue("username"))
524 password := r.FormValue("password")
525 next := r.FormValue("next")
526 if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
527 next = "/"
528 }
529 u, err := w.Auth.Authenticate(username, password)
530 if err != nil {
531 p := w.base(r, "Sign in")
532 p.Error = "invalid username or password"
533 w.render(rw, "login", struct {
534 Page
535 Next string
536 }{p, next}, http.StatusUnauthorized)
537 return
538 }
539 sid, err := w.Auth.CreateSession(u.ID, w.Cfg.SessionTTLDur)
540 if err != nil {
541 http.Error(rw, "cannot create session: "+err.Error(), 500)
542 return
543 }
544 http.SetCookie(rw, &http.Cookie{
545 Name: SessionCookie,
546 Value: sid,
547 Path: "/",
548 HttpOnly: true,
549 SameSite: http.SameSiteLaxMode,
550 Secure: r.TLS != nil,
551 Expires: time.Now().Add(w.Cfg.SessionTTLDur),
552 })
553 http.Redirect(rw, r, next, http.StatusSeeOther)
554 }
555
556 func (w *Web) Logout(rw http.ResponseWriter, r *http.Request) {
557 if c, err := r.Cookie(SessionCookie); err == nil {
558 _ = w.Auth.DeleteSession(c.Value)
559 }
560 http.SetCookie(rw, &http.Cookie{Name: SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
561 http.Redirect(rw, r, "/login", http.StatusSeeOther)
562 }
563
564 // ---------- helpers ----------
565
566 func cloneURL(r *http.Request, name string) string {
567 scheme := "http"
568 if r.TLS != nil {
569 scheme = "https"
570 }
571 if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
572 scheme = p
573 }
574 host := r.Host
575 return fmt.Sprintf("%s://%s/git/%s.git", scheme, host, name)
576 }
577
578 func safePath(p string) bool {
579 if strings.Contains(p, "\x00") {
580 return false
581 }
582 for _, seg := range strings.Split(p, "/") {
583 if seg == ".." {
584 return false
585 }
586 }
587 return true
588 }
589
590 type crumb struct {
591 Name string
592 Path string
593 }
594
595 func crumbs(ref, p string) []crumb {
596 out := []crumb{{Name: strings.SplitN(ref, "/", 2)[0], Path: ""}}
597 cur := ""
598 parts := strings.Split(p, "/")
599 for i, seg := range parts {
600 if seg == "" {
601 continue
602 }
603 if cur == "" {
604 cur = seg
605 } else {
606 cur += "/" + seg
607 }
608 if i == len(parts)-1 {
609 out = append(out, crumb{Name: seg, Path: cur})
610 } else {
611 out = append(out, crumb{Name: seg, Path: cur})
612 }
613 }
614 return out
615 }
616
617 func settingsURL(name, errMsg string) string {
618 u := "/r/" + url.PathEscape(name) + "/settings"
619 if errMsg != "" {
620 u += "?err=" + url.QueryEscape(errMsg)
621 }
622 return u
623 }
624
625 func shortSHA(s string) string {
626 if len(s) > 8 {
627 return s[:8]
628 }
629 return s
630 }
631
632 func humanSince(t int64) string {
633 if t == 0 {
634 return ""
635 }
636 d := time.Since(time.Unix(t, 0))
637 switch {
638 case d < time.Minute:
639 return "just now"
640 case d < time.Hour:
641 return fmt.Sprintf("%dm ago", int(d.Minutes()))
642 case d < 24*time.Hour:
643 return fmt.Sprintf("%dh ago", int(d.Hours()))
644 default:
645 return fmt.Sprintf("%dd ago", int(d.Hours()/24))
646 }
647 }