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.

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/transport/transport_test.go +89−170

@@ -1,19 +1,16 @@
1 1 package transport
2 2
3 3 import (
4 "bytes"
5 "context"
6 "errors"
7 4 "io"
8 5 "log/slog"
9 6 "net/http"
10 7 "net/http/httptest"
11 "os"
12 8 "path/filepath"
13 9 "strings"
14 10 "testing"
15 11
16 12 "github.com/rodent-software/vole/internal/acl"
13 "github.com/rodent-software/vole/internal/gitx"
17 14 "github.com/rodent-software/vole/internal/repo"
18 15 )
19 16
@@ -23,11 +20,9 @@ func TestRoute(t *testing.T) {
23 20 ok bool
24 21 }{
25 22 {"/git/demo.git/info/refs", "demo", "info/refs", true},
26 {"/git/demo.git/git-receive-pack", "demo", "git-receive-pack", true},
27 23 {"/git/demo.git/git-upload-pack", "demo", "git-upload-pack", true},
28 24 {"/git/demo/info/refs", "", "", false},
29 25 {"/other/demo.git/info/refs", "", "", false},
30 {"/git/", "", "", false},
31 26 } {
32 27 name, rest, ok := Route(c.path)
33 28 if ok != c.ok || name != c.name || rest != c.rest {
@@ -36,213 +31,137 @@ func TestRoute(t *testing.T) {
36 31 }
37 32 }
38 33
39 func TestClassify(t *testing.T) {
40 if op, need, err := classify("info/refs", "service=git-receive-pack"); err != nil || op != OpPush || need != acl.Write {
41 t.Fatalf("receive-pack info/refs: op=%v need=%v err=%v", op, need, err)
42 }
43 if op, need, err := classify("info/refs", "service=git-upload-pack"); err != nil || op != OpRead || need != acl.Read {
44 t.Fatalf("upload-pack info/refs: op=%v need=%v err=%v", op, need, err)
45 }
46 if _, _, err := classify("info/refs", ""); err == nil {
47 t.Fatal("dumb http should be refused explicitly")
48 }
49 if _, _, err := classify("HEAD", ""); err == nil {
50 t.Fatal("unknown path should be refused")
34 func TestIsPush(t *testing.T) {
35 for _, c := range []struct {
36 rest, query string
37 want bool
38 }{
39 {"info/refs", "service=git-receive-pack", true},
40 {"info/refs", "service=git-upload-pack", false},
41 {"git-receive-pack", "", true},
42 {"git-upload-pack", "", false},
43 } {
44 if got := IsPush(c.rest, c.query); got != c.want {
45 t.Errorf("IsPush(%q,%q)=%v want %v", c.rest, c.query, got, c.want)
46 }
51 47 }
52 48 }
53 49
54 // TestSpoolJanitorRemovesOrphans proves a crash-leftover is cleaned up while a
55 // live upload is untouched.
56 func TestSpoolJanitorRemovesOrphans(t *testing.T) {
57 dir := t.TempDir()
58 s := NewSpooler(dir)
59
60 orphan := filepath.Join(dir, spoolPrefix+"orphan")
61 if err := os.WriteFile(orphan, []byte("leftover"), 0o600); err != nil {
50 // makeRepo creates a repo with one commit and returns its name.
51 func makeRepo(t *testing.T, root string, public bool) string {
52 t.Helper()
53 st := repo.New(root, "/bin/true")
54 m, err := st.Create("demo", "owner", "")
55 if err != nil {
62 56 t.Fatal(err)
63 57 }
64 live, err := s.Create()
58 g := gitx.Open(m.Dir)
59 blob, err := g.HashObject([]byte("hello\n"))
65 60 if err != nil {
66 61 t.Fatal(err)
67 62 }
68 if _, err := live.WriteString("in flight"); err != nil {
63 tree, err := g.MkTree([]gitx.Entry{{Mode: "100644", Type: "blob", SHA: blob, Name: "readme.md"}})
64 if err != nil {
69 65 t.Fatal(err)
70 66 }
71
72 n, err := s.Janitor()
67 commit, err := g.CommitTree(tree, "", "init")
73 68 if err != nil {
74 69 t.Fatal(err)
75 70 }
76 if n != 1 {
77 t.Fatalf("janitor removed %d files, want 1", n)
78 }
79 if _, err := os.Stat(orphan); !os.IsNotExist(err) {
80 t.Fatal("orphan survived the janitor")
81 }
82 if _, err := os.Stat(live.Name()); err != nil {
83 t.Fatal("janitor deleted a live spool file")
84 }
85 s.Release(live)
86 if _, err := os.Stat(live.Name()); !os.IsNotExist(err) {
87 t.Fatal("Release did not delete the spool file")
71 if err := g.UpdateRef("refs/heads/main", commit); err != nil {
72 t.Fatal(err)
88 73 }
89 if n, _ := s.Janitor(); n != 0 {
90 t.Fatalf("janitor found %d files after release", n)
74 if public {
75 a, err := m.ACL()
76 if err != nil {
77 t.Fatal(err)
78 }
79 a.Default = "read"
80 if err := m.SetACL(a, "test"); err != nil {
81 t.Fatal(err)
82 }
91 83 }
84 return m.Name
92 85 }
93 86
94 func testProxy(t *testing.T, maxPush int64) (*Proxy, *repo.Store, string) {
87 func newProxy(t *testing.T, public bool) *Proxy {
95 88 t.Helper()
96 root := t.TempDir()
97 st := repo.New(filepath.Join(root, "repos"), "/bin/true")
98 if _, err := st.Create("demo", "owner", ""); err != nil {
99 t.Fatal(err)
100 }
101 p := New(Config{
102 Root: filepath.Join(root, "repos"),
103 Open: st.Open,
104 Actor: func(u, pw string) (string, error) { return u, nil },
105 MaxPushBytes: maxPush,
106 SpoolDir: filepath.Join(root, "spool"),
107 Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
89 root := filepath.Join(t.TempDir(), "repos")
90 makeRepo(t, root, public)
91 st := repo.New(root, "/bin/true")
92 return New(Config{
93 Root: root,
94 Open: st.Open,
95 Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
108 96 })
109 return p, st, filepath.Join(root, "spool")
110 97 }
111 98
112 func TestDeclaredOversizeRejectedBeforeBody(t *testing.T) {
113 p, _, _ := testProxy(t, 64)
114 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader("payload"))
115 req.ContentLength = 128
116 // The repo's owner is authenticated as itself; the ACL grants admin.
117 req.SetBasicAuth("owner", "pw")
99 func TestPrivateRepoIs404(t *testing.T) {
100 p := newProxy(t, false)
101 req := httptest.NewRequest(http.MethodGet, "/git/demo.git/info/refs?service=git-upload-pack", nil)
118 102 rec := httptest.NewRecorder()
119 103 p.ServeHTTP(rec, req)
120 if rec.Code != http.StatusRequestEntityTooLarge {
121 t.Fatalf("status=%d want 413", rec.Code)
104 if rec.Code != http.StatusNotFound {
105 t.Fatalf("private repo answered %d, want 404 (403 would confirm it exists)", rec.Code)
122 106 }
123 107 }
124 108
125 // TestAbandonedChunkedUploadLeavesNoSpoolFile drives the cleanup path that a
126 // real client disconnect takes: the body read fails part way through.
127 func TestAbandonedChunkedUploadLeavesNoSpoolFile(t *testing.T) {
128 p, _, spoolDir := testProxy(t, 1<<20)
129 body := &failingReader{failAfter: 128}
130 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", body)
131 req.ContentLength = -1 // chunked: forces the spool path
132 req.SetBasicAuth("owner", "pw")
109 func TestPublicRepoServesUploadPack(t *testing.T) {
110 p := newProxy(t, true)
111 req := httptest.NewRequest(http.MethodGet, "/git/demo.git/info/refs?service=git-upload-pack", nil)
133 112 rec := httptest.NewRecorder()
134 113 p.ServeHTTP(rec, req)
135
136 ents, err := os.ReadDir(spoolDir)
137 if err != nil && !os.IsNotExist(err) {
138 t.Fatal(err)
139 }
140 for _, e := range ents {
141 if strings.HasPrefix(e.Name(), spoolPrefix) {
142 t.Fatalf("aborted upload left spool file %s", e.Name())
143 }
144 }
145 if rec.Code == http.StatusOK {
146 t.Fatal("aborted upload should not report success")
147 }
148 }
149
150 type failingReader struct{ failAfter int }
151
152 func (f *failingReader) Read(p []byte) (int, error) {
153 if f.failAfter <= 0 {
154 return 0, errors.New("client went away")
114 if rec.Code != http.StatusOK {
115 t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
155 116 }
156 n := len(p)
157 if n > f.failAfter {
158 n = f.failAfter
117 if !strings.Contains(rec.Body.String(), "refs/heads/main") {
118 t.Fatalf("advertisement missing refs: %q", rec.Body.String())
159 119 }
160 buf := bytes.Repeat([]byte("x"), n)
161 copy(p, buf)
162 f.failAfter -= n
163 return n, nil
164 120 }
165 121
166 // TestChunkedUploadSpoolsToDiskThenServes checks that a chunked body is written
167 // to a temp file, gets a real CONTENT_LENGTH, and is cleaned up afterwards.
168 func TestChunkedUploadSpoolsToDiskThenServes(t *testing.T) {
169 p, _, spoolDir := testProxy(t, 1<<20)
170 payload := strings.Repeat("a", 4096)
171 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader(payload))
172 req.ContentLength = -1
173 req.SetBasicAuth("owner", "pw")
174 rec := httptest.NewRecorder()
175 p.ServeHTTP(rec, req)
176
177 if got := p.Stats()["spooled"]; got != int64(len(payload)) {
178 t.Fatalf("spooled=%d want %d", got, len(payload))
179 }
180 ents, _ := os.ReadDir(spoolDir)
181 for _, e := range ents {
182 if strings.HasPrefix(e.Name(), spoolPrefix) {
183 t.Fatalf("spool file not cleaned up: %s", e.Name())
122 func TestPushOverHTTPRefused(t *testing.T) {
123 p := newProxy(t, true)
124 for _, target := range []string{
125 "/git/demo.git/info/refs?service=git-receive-pack",
126 "/git/demo.git/git-receive-pack",
127 } {
128 req := httptest.NewRequest(http.MethodPost, target, strings.NewReader("x"))
129 req.ContentLength = 1
130 rec := httptest.NewRecorder()
131 p.ServeHTTP(rec, req)
132 if rec.Code != http.StatusForbidden {
133 t.Fatalf("%s: status = %d, want 403", target, rec.Code)
134 }
135 if !strings.Contains(rec.Body.String(), "SSH") {
136 t.Fatalf("%s: message should point at SSH: %s", target, rec.Body.String())
184 137 }
185 138 }
139 if got := p.Stats()["push_attempts"]; got != 2 {
140 t.Fatalf("push_attempts = %d, want 2", got)
141 }
186 142 }
187 143
188 // TestDisconnectedClientSkipsBackend: once the client is gone, do not burn a
189 // receive-pack on the upload, and still clean up the spool file.
190 func TestDisconnectedClientSkipsBackend(t *testing.T) {
191 p, _, spoolDir := testProxy(t, 1<<20)
192 ctx, cancel := context.WithCancel(context.Background())
193 cancel()
194 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader(strings.Repeat("a", 2048))).WithContext(ctx)
144 func TestChunkedReadBodyRefused(t *testing.T) {
145 p := newProxy(t, true)
146 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-upload-pack", strings.NewReader("want x"))
195 147 req.ContentLength = -1
196 req.SetBasicAuth("owner", "pw")
197 148 rec := httptest.NewRecorder()
198 149 p.ServeHTTP(rec, req)
199
200 if ent := readSpool(t, spoolDir); len(ent) != 0 {
201 t.Fatalf("spool not cleaned after disconnect: %v", ent)
150 if rec.Code != http.StatusLengthRequired {
151 t.Fatalf("status = %d, want 411", rec.Code)
202 152 }
203 153 }
204 154
205 func readSpool(t *testing.T, dir string) []string {
206 t.Helper()
207 ents, err := os.ReadDir(dir)
208 if err != nil && !os.IsNotExist(err) {
209 t.Fatal(err)
210 }
211 var out []string
212 for _, e := range ents {
213 if strings.HasPrefix(e.Name(), spoolPrefix) {
214 out = append(out, e.Name())
155 func TestDumbHTTPRefused(t *testing.T) {
156 p := newProxy(t, true)
157 for _, target := range []string{"/git/demo.git/HEAD", "/git/demo.git/objects/info/packs"} {
158 req := httptest.NewRequest(http.MethodGet, target, nil)
159 rec := httptest.NewRecorder()
160 p.ServeHTTP(rec, req)
161 if rec.Code != http.StatusNotFound {
162 t.Fatalf("%s: status = %d, want 404", target, rec.Code)
215 163 }
216 164 }
217 return out
218 165 }
219 166
220 func TestBadCredentialsAre401Not403(t *testing.T) {
221 p, _, _ := testProxy(t, 1<<20)
222 p.cfg.Actor = func(u, pw string) (string, error) { return "", errors.New("nope") }
223 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader("x"))
224 req.ContentLength = 1
225 req.SetBasicAuth("owner", "wrong")
226 rec := httptest.NewRecorder()
227 p.ServeHTTP(rec, req)
228 if rec.Code != http.StatusUnauthorized {
229 t.Fatalf("status=%d want 401 for bad credentials", rec.Code)
230 }
231 if body := rec.Body.String(); strings.Contains(body, "anonymous") {
232 t.Fatalf("message should not describe a bad-credential request as anonymous: %s", body)
233 }
234 }
235
236 func TestAnonymousPushChallenges(t *testing.T) {
237 p, _, _ := testProxy(t, 1<<20)
238 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader("x"))
239 req.ContentLength = 1
240 rec := httptest.NewRecorder()
241 p.ServeHTTP(rec, req)
242 if rec.Code != http.StatusUnauthorized {
243 t.Fatalf("status=%d want 401", rec.Code)
244 }
245 if rec.Header().Get("WWW-Authenticate") == "" {
246 t.Fatal("401 must carry a WWW-Authenticate header so git can prompt")
247 }
248 }
167 var _ = acl.Read