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.

Move writes to SSH, drop web authentication, keep identity in git

a5f19b3c19861e080953096f934df25dc38beaf7 · Keenan Nemetz · 18d ago · parent 653e0cfc

Move writes to SSH, drop web authentication, keep identity in git

The authenticated-HTTP design is gone rather than extended. Writes now require
an SSH key, the web is read-only, and the user roster lives in git.

What replaced what:

- internal/sshserver is the only write transport. An embedded SSH server
  (golang.org/x/crypto/ssh) authenticates by public key and hands the principal
  to pre-receive through the environment, so the hook's policy code is unchanged.
  The exec surface is whitelisted to git-upload-pack and git-receive-pack, the
  repository argument is resolved through the store rather than concatenated, and
  everything else -- including `sh -c` -- is refused and asserted refused in a
  test.
- internal/identity stores users on refs/heads/main in data/system.git. The
  directory listing is the table: users/<name>/keys/<sha>.pub. Writes are CAS
  commits, reads come from an in-memory snapshot so the SSH auth callback is a
  map lookup. Nothing stored is a secret, so a clone of system.git is a complete
  backup and git log on it is the audit trail.
- internal/transport is now anonymous read-only: upload-pack behind a visibility
  check, 404 for private repositories, and no push path at all.
- internal/web has no login, no sessions and no mutations.

Deleted outright: internal/auth, the Basic-auth path, login/logout, web
settings/rename/delete, chunked body spooling, the spool janitor, the push
semaphore and the hand-rolled byte cap. The cap is now git's receive.maxInputSize
per repo, which enforces it on every transport without buffering.

Three things worth recording:

- The CAS helper read the tree outside the retry loop and compared against a
  freshly-read old value, so a concurrent writer's commit could be silently
  discarded while the CAS "succeeded". A test with two stores adding eight keys
  at once found it losing half of them; read, mutate and compare now all happen
  inside the loop.
- The roster moved from refs/vole/users to refs/heads/main because `git clone`
  only mirrors refs/heads/*: the documented backup command produced an instance
  with zero users. Storing on a branch also means git itself refuses to point the
  ref at a non-commit.
- Exec refusals are answered on the channel rather than by rejecting the request.
  Rejecting made the client print "exec request failed on channel 0", so the
  reason was unreadable.

Verified by hand as well as by test: init -> user -> add-key -> repo -> serve,
push over SSH, anonymous HTTP clone, web browse, a private repo answering 404 on
both anonymous surfaces while still cloning over SSH, a refused `sh -c` that
prints why and executes nothing, and a plain clone of system.git yielding the
whole roster with no side database present.

internal/web/web.go +62−352

@@ -1,6 +1,9 @@
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.
1 // Package web is Vole's read-only HTML surface.
2 //
3 // There is no login: everything the web serves already has to be readable by an
4 // anonymous principal, which is the same rule the HTTP git transport enforces.
5 // Repositories that are not public answer 404 rather than 403, so the web never
6 // confirms that a private repository exists.
4 7 package web
5 8
6 9 import (
@@ -13,88 +16,48 @@ import (
13 16 "unicode/utf8"
14 17
15 18 "github.com/rodent-software/vole/internal/acl"
16 "github.com/rodent-software/vole/internal/auth"
17 19 "github.com/rodent-software/vole/internal/config"
18 20 "github.com/rodent-software/vole/internal/gitx"
19 21 "github.com/rodent-software/vole/internal/index"
20 22 "github.com/rodent-software/vole/internal/repo"
21 23 )
22 24
23 const SessionCookie = "vole_session"
24
25 25 type Web struct {
26 26 Repos *repo.Store
27 Auth *auth.Store
28 27 Index *index.Store
29 28 Cfg *config.Config
30 29 Tmpl *template.Template
31 30 Logger func(format string, args ...any)
32 31 }
33 32
34 func New(repos *repo.Store, a *auth.Store, ix *index.Store, cfg *config.Config) (*Web, error) {
33 func New(repos *repo.Store, ix *index.Store, cfg *config.Config) (*Web, error) {
35 34 t, err := template.New("vole").Funcs(template.FuncMap{
36 35 "since": humanSince,
37 36 "short": shortSHA,
38 37 "esc": url.PathEscape,
39 "join": func(v []string) string { return strings.Join(v, "\n") },
40 "list": func(v ...string) []string { return v },
41 38 }).Parse(templates)
42 39 if err != nil {
43 40 return nil, err
44 41 }
45 w := &Web{Repos: repos, Auth: a, Index: ix, Cfg: cfg, Tmpl: t}
42 w := &Web{Repos: repos, Index: ix, Cfg: cfg, Tmpl: t}
46 43 w.Logger = func(string, ...any) {}
47 44 return w, nil
48 45 }
49 46
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
47 // Page is embedded by every view. Exported so html/template can promote its
48 // fields; an unexported embedded type is not reachable from a template.
49 type Page struct {
50 Title string
51 Error string
77 52 }
78 53
79 func (w *Web) roleFor(m *repo.Meta, u *auth.User) acl.Role {
54 // public reports whether the repository may be served to an anonymous reader.
55 func (w *Web) public(m *repo.Meta) bool {
80 56 a, err := m.ACL()
81 57 if err != nil {
82 return acl.None
83 }
84 if u == nil {
85 return a.RoleFor("")
58 return false
86 59 }
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
60 return a.Can("", acl.Read)
98 61 }
99 62
100 63 func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) {
@@ -107,101 +70,45 @@ func (w *Web) render(rw http.ResponseWriter, name string, data any, status int)
107 70 }
108 71 }
109 72
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 }
73 func (w *Web) base(title string) Page { return Page{Title: title} }
132 74
133 75 // ---------- handlers ----------
134 76
135 77 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()
78 entries, err := w.Index.List()
141 79 if err != nil {
142 80 http.Error(rw, "index unavailable: "+err.Error(), 500)
143 81 return
144 82 }
145 // Only show repos the user can read.
146 83 var visible []index.Entry
147 for _, e := range repos {
84 for _, e := range entries {
148 85 m, err := w.Repos.Open(e.Name)
149 86 if err != nil {
150 87 continue
151 88 }
152 if w.roleFor(m, u).AtLeast(acl.Read) {
89 if w.public(m) {
153 90 visible = append(visible, e)
154 91 }
155 92 }
156 93 w.render(rw, "index", struct {
157 94 Page
158 95 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)
96 }{w.base("Repositories"), visible}, 0)
182 97 }
183 98
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 {
99 // open resolves a repository for an anonymous reader, answering 404 when it is
100 // missing or not public.
101 func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) {
102 m, err := w.Repos.Open(r.PathValue("name"))
103 if err != nil || !w.public(m) {
192 104 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
105 return nil, false
199 106 }
200 return m, u, role, true
107 return m, true
201 108 }
202 109
203 110 func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) {
204 m, _, role, ok := w.repo(rw, r)
111 m, ok := w.open(rw, r)
205 112 if !ok {
206 113 return
207 114 }
@@ -229,22 +136,20 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) {
229 136 Name string
230 137 Description string
231 138 Ref string
232 Role acl.Role
233 RepoAdmin bool
234 139 CloneURL string
140 SSHURL string
235 141 Entries []gitx.Entry
236 142 Commits []gitx.Commit
237 143 Commit string
238 144 Count int
239 145 Branches []string
240 146 }{
241 Page: w.base(r, m.Name),
147 Page: w.base(m.Name),
242 148 Name: m.Name,
243 149 Description: m.Description,
244 150 Ref: ref,
245 Role: role,
246 RepoAdmin: role.AtLeast(acl.Admin),
247 151 CloneURL: cloneURL(r, m.Name),
152 SSHURL: sshURL(w.Cfg.SSHAddr, m.Name),
248 153 Entries: entries,
249 154 Commits: commits,
250 155 Commit: commit,
@@ -254,7 +159,7 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) {
254 159 }
255 160
256 161 func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) {
257 m, _, _, ok := w.repo(rw, r)
162 m, ok := w.open(rw, r)
258 163 if !ok {
259 164 return
260 165 }
@@ -275,7 +180,7 @@ func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) {
275 180 }
276 181 entries, err := g.LsTree(treeish)
277 182 if err != nil {
278 http.Error(rw, "not a directory: "+err.Error(), http.StatusBadRequest)
183 http.Error(rw, "not a directory", http.StatusBadRequest)
279 184 return
280 185 }
281 186 w.render(rw, "tree", struct {
@@ -285,21 +190,17 @@ func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) {
285 190 Path string
286 191 Entries []gitx.Entry
287 192 Crumbs []crumb
288 }{w.base(r, m.Name+" · "+ref), m.Name, ref, p, entries, crumbs(ref, p)}, 0)
193 }{w.base(m.Name + " · " + ref), m.Name, ref, p, entries, crumbs(p)}, 0)
289 194 }
290 195
291 func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) {
292 w.blob(rw, r, false)
293 }
196 func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) }
294 197
295 func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) {
296 w.blob(rw, r, true)
297 }
198 func (w *Web) Raw(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, true) }
298 199
299 200 const maxInlineBytes = 1 << 20
300 201
301 202 func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) {
302 m, _, _, ok := w.repo(rw, r)
203 m, ok := w.open(rw, r)
303 204 if !ok {
304 205 return
305 206 }
@@ -341,11 +242,11 @@ func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) {
341 242 Truncated bool
342 243 Size int64
343 244 Crumbs []crumb
344 }{w.base(r, m.Name+" · "+p), m.Name, ref, p, content, binary, truncated, int64(len(data)), crumbs(ref, p)}, 0)
245 }{w.base(m.Name + " · " + p), m.Name, ref, p, content, binary, truncated, int64(len(data)), crumbs(p)}, 0)
345 246 }
346 247
347 248 func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) {
348 m, _, _, ok := w.repo(rw, r)
249 m, ok := w.open(rw, r)
349 250 if !ok {
350 251 return
351 252 }
@@ -366,199 +267,7 @@ func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) {
366 267 Name string
367 268 Ref string
368 269 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)
270 }{w.base(m.Name + " · history"), m.Name, ref, commits}, 0)
562 271 }
563 272
564 273 // ---------- helpers ----------
@@ -571,8 +280,22 @@ func cloneURL(r *http.Request, name string) string {
571 280 if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
572 281 scheme = p
573 282 }
574 host := r.Host
575 return fmt.Sprintf("%s://%s/git/%s.git", scheme, host, name)
283 return fmt.Sprintf("%s://%s/git/%s.git", scheme, r.Host, name)
284 }
285
286 func sshURL(addr, name string) string {
287 host, port, ok := strings.Cut(addr, ":")
288 if !ok {
289 host = addr
290 }
291 return fmt.Sprintf("ssh://git@%s/%s.git", netJoin(host, port), name)
292 }
293
294 func netJoin(host, port string) string {
295 if port == "" || port == "22" {
296 return host
297 }
298 return host + ":" + port
576 299 }
577 300
578 301 func safePath(p string) bool {
@@ -592,11 +315,10 @@ type crumb struct {
592 315 Path string
593 316 }
594 317
595 func crumbs(ref, p string) []crumb {
596 out := []crumb{{Name: strings.SplitN(ref, "/", 2)[0], Path: ""}}
318 func crumbs(p string) []crumb {
319 var out []crumb
597 320 cur := ""
598 parts := strings.Split(p, "/")
599 for i, seg := range parts {
321 for _, seg := range strings.Split(p, "/") {
600 322 if seg == "" {
601 323 continue
602 324 }
@@ -605,23 +327,11 @@ func crumbs(ref, p string) []crumb {
605 327 } else {
606 328 cur += "/" + seg
607 329 }
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 }
330 out = append(out, crumb{Name: seg, Path: cur})
613 331 }
614 332 return out
615 333 }
616 334
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 335 func shortSHA(s string) string {
626 336 if len(s) > 8 {
627 337 return s[:8]