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