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 | ||