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 · 17d 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.
docs/roadmap.md +48−20
| @@ -22,12 +22,15 @@ throughout; see [Non-goals](#non-goals) for what they rule out. | |||
| 22 | 22 | |---|---|---| | |
| 23 | 23 | | State model | Git is authoritative; SQLite is a derived read model | Rebuildable index means storage is a swappable detail | | |
| 24 | 24 | | Git integration | Shell out to the `git` binary | Hooks, CAS ref updates, and full `receive-pack` semantics for free; go-git lacks all three | | |
| 25 | | Transport | Smart HTTP via `git http-backend` | Battle-tested; SSH transport deferred | | ||
| 25 | | Transport | SSH for writes (embedded server), smart HTTP for anonymous reads | One authenticated write path; `git` already speaks it, keys already exist | | ||
| 26 | 26 | | Metadata storage | Sharded sidecar refs (`refs/vole/issues/<shard>`, `refs/vole/crs`, `refs/vole/acl`) | Parallel writers, no single-ref contention, still fetchable as one glob | | |
| 27 | 27 | | Metadata writes | Compare-and-swap ref updates, retry on loss | No lock server, no lost updates | | |
| 28 | | ACL enforcement | `pre-receive` hook, mirrored in web handlers | Web-only gating makes `git push` the bypass | | ||
| 28 | | ACL enforcement | `pre-receive` only | One enforcement point; the web has no writes to gate | | ||
| 29 | 29 | | Binary layout | One static CGO-free binary, three modes (`serve`, `runner`, `hook`) | Drop-anywhere deployment | | |
| 30 | 30 | | Index store | SQLite via `modernc.org/sqlite` | CGO-free; rebuildable, so swapping engines later is cheap | | |
| 31 | | Identity | User roster in `data/system.git` on `refs/heads/main` | Public keys only, so a clone is a complete backup that leaks nothing | | ||
| 32 | | Web authentication | None: the web is read-only | No password store, no login, no session tokens | | ||
| 33 | | Secrets | Instance key + SSH host key, `0600` on disk | A private key in a repo would be cloned and eventually pushed | ||
| 31 | 34 | | Config | TOML on disk, no database-backed settings | One box, one file, greppable | | |
| 32 | 35 | ||
| 33 | 36 | ## Non-goals | |
| @@ -83,25 +86,40 @@ The unglamorous foundation. If this phase isn't solid, nothing above it is trust | |||
| 83 | 86 | ||
| 84 | 87 | | Criterion | Verified by | | |
| 85 | 88 | |---|---| | |
| 86 | | Authenticated clone/push/fetch on a fresh instance | `TestPushThenClone` — real `git` against a real listener | | ||
| 87 | | Unauthorized push rejected by the hook with an actionable message | `TestReadOnlyPushRejectedAndRefUnmoved`, `TestAnonymousPushRejected` | | ||
| 88 | | `vole index rebuild` reproduces state after deleting the index | `TestIndexRebuildReproducesState` — deletes the DB, rebuilds via the CLI, compares identity and ACL sha to the ref | | ||
| 89 | | Rejected push leaves no partial ref update | asserted by comparing the ref before/after in `TestReadOnlyPushRejectedAndRefUnmoved` | | ||
| 90 | | Aborted push leaves no ref and no spooled temp file | `TestAbandonedChunkedUploadLeavesNoSpoolFile`, `TestDisconnectedClientSkipsBackend`, plus a manual 256 MiB kill mid-upload: no ref, empty spool, no orphans | | ||
| 91 | | Second concurrent push is queued, not doubled | `TestConcurrentPushesQueueRatherThanFail` with `push_concurrency = 1`; both pushes land, neither 503s | | ||
| 92 | |||
| 93 | **Deviations from the plan, and why:** | ||
| 89 | | Authenticated push/fetch/clone over SSH | `TestPushAndCloneOverSSH`, `TestPushOverSSHThenReadOverBothTransports` | | ||
| 90 | | Unauthorized push rejected, with the SSH identity named | `TestReadOnlyUserCannotPush` — the message names the user, proving the key reached `pre-receive` | | ||
| 91 | | Rejected push leaves no partial ref update | ref compared before/after in `TestReadOnlyUserCannotPush` | | ||
| 92 | | A session cannot be used as a shell | `TestExecWhitelistRefusesShell` (incl. `sh -c`), `TestShellRequestRefused`, `TestRepoOutsideTheStoreIsRefused` | | ||
| 93 | | Unregistered key cannot connect | `TestUnknownKeyRejected` | | ||
| 94 | | `vole index rebuild` reproduces state after deleting the index | `TestRebuildIndexAfterDelete` | | ||
| 95 | | Identity survives without a side database | `TestIdentitySurvivesWithoutSideDatabase` — a plain clone of `system.git` is the whole roster | | ||
| 96 | | Private repos are invisible, not forbidden | `TestPrivateRepoIsInvisible` — 404 on web and HTTP, absent from the index, still cloneable over SSH | | ||
| 97 | |||
| 98 | **Revision after Phase 1 shipped.** The first cut authenticated HTTP with | ||
| 99 | passwords and PATs, and the web had logins and settings pages. That was | ||
| 100 | replaced, not extended: passwords were dropped, writes moved to SSH, and the web | ||
| 101 | became read-only. The result is smaller than what it replaced — the push path | ||
| 102 | over HTTP (chunked spooling, spool janitor, push semaphore, byte cap), the auth | ||
| 103 | package, and every web mutation handler were deleted. Two things replaced the | ||
| 104 | push cap honestly: `receive.maxInputSize` in each repo, and the per-blob check | ||
| 105 | that was already in `pre-receive`. | ||
| 106 | |||
| 107 | Deviations from the original plan, and why: | ||
| 94 | 108 | ||
| 95 | 109 | - **`pre-receive` only, no `update` hook.** One enforcement point with | |
| 96 | all-or-nothing semantics is easier to reason about than two that can disagree, | ||
| 97 | and it is what makes "a rejected push moves no refs" true by construction. | ||
| 98 | - **`refs/vole/*` is rejected for client pushes.** Not in the original plan, and | ||
| 99 | essential: the ACL itself lives on `refs/vole/acl`, so permitting client | ||
| 100 | writes there would let any writer grant themselves admin. | ||
| 101 | - **`push_concurrency` defaults to 1.** Serialising pushes is the honest | ||
| 102 | response to the spike's 1.13 GB-per-GiB-push measurement on a single box. | ||
| 103 | - **Invalid credentials answer 401, not 403.** Found during a manual smoke test: | ||
| 104 | 403 both leaked that a repo exists and told the client not to retry. | ||
| 110 | all-or-nothing semantics; it is what makes "a rejected push moves no refs" | ||
| 111 | true by construction. | ||
| 112 | - **`refs/vole/*` is rejected for client pushes.** The ACL lives there, so | ||
| 113 | permitting client writes would let a writer self-grant admin. | ||
| 114 | - **The roster lives on `refs/heads/main`, not `refs/vole/users`.** A custom | ||
| 115 | namespace is invisible to `git clone`, which only mirrors `refs/heads/*` — the | ||
| 116 | obvious backup command silently produced an instance with no users. The system | ||
| 117 | repo is never pushed to, so it gains nothing from a reserved namespace. | ||
| 118 | - **No WebAuthn.** It requires a secure context, which would have pulled TLS | ||
| 119 | forward from Phase 6 to authenticate a web surface that no longer accepts | ||
| 120 | writes. | ||
| 121 | - **Exec refusals are answered on the channel**, not by rejecting the request, | ||
| 122 | so the client can read the reason instead of `exec request failed on channel 0`. | ||
| 105 | 123 | ||
| 106 | 124 | ### Phase 2 — Metadata layer and issues | |
| 107 | 125 | ||
| @@ -130,7 +148,8 @@ measured the ref path alone, so the index is the unmeasured half and must be pro | |||
| 130 | 148 | - CR entity on `refs/vole/crs` referencing base branch + head branch + head SHA | |
| 131 | 149 | - Diff view (three-dot against merge base), file tree, per-file collapse for large diffs | |
| 132 | 150 | - Line-anchored review comments that survive force-pushes via `(path, blob-sha, line)` anchoring | |
| 133 | - Review states: comment / approve / request changes | ||
| 151 | - Review states: comment / approve / request changes, on whichever surface the | ||
| 152 | open question above resolves to | ||
| 134 | 153 | - Merge as a CAS update of the base ref from the reviewed head SHA; reject stale merges | |
| 135 | 154 | rather than clobbering | |
| 136 | 155 | - Protected branches: required approvals, required status checks (wired in Phase 4) | |
| @@ -267,6 +286,10 @@ Filled in as phases land. Append; don't rewrite. | |||
| 267 | 286 | | 2026-10-22 | Clients may not push `refs/vole/*` | The ACL lives there; allowing it would let a writer self-grant admin | | |
| 268 | 287 | | 2026-10-22 | SQLite via `modernc.org/sqlite` (two files: auth, index) | CGO-free single binary; the index file is disposable, auth is not | | |
| 269 | 288 | | 2026-10-22 | Bad credentials return 401 with a challenge | 403 leaked repo existence and suppressed client retry | | |
| 289 | | 2026-10-22 | Writes move to SSH; HTTP becomes anonymous read-only | Removes password storage, login, sessions and the token table outright | | ||
| 290 | | 2026-10-22 | Web authentication dropped; the web is read-only | Read-only content is exactly what an anonymous principal may see | | ||
| 291 | | 2026-10-22 | Identity in `system.git`, roster on `refs/heads/main` | Public keys only; a plain clone is a complete, non-leaking backup | | ||
| 292 | | 2026-10-22 | `receive.maxInputSize` replaces the HTTP byte cap | git enforces it on both transports; the proxy never buffers a pack | | ||
| 270 | 293 | ||
| 271 | 294 | ## Open questions | |
| 272 | 295 | ||
| @@ -277,3 +300,8 @@ Filled in as phases land. Append; don't rewrite. | |||
| 277 | 300 | being sufficient. Defer until someone actually complains. | |
| 278 | 301 | - Email deliverability: rely on operator SMTP, or bundle a fallback? Leaning operator SMTP | |
| 279 | 302 | only, with a loud warning when unconfigured. | |
| 303 | - **Change-request approvals need an authenticated write surface**, which the | ||
| 304 | read-only web cannot provide. Either review moves to the CLI over SSH | ||
| 305 | (`vole review approve 12`, authenticated by the same key, updating a CR ref), | ||
| 306 | or authentication returns for that one surface in Phase 3. Decide before | ||
| 307 | designing the review UI. | ||