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.
Phase 1: a working git remote with access control
653e0cfc227c992e5b26ea10057aa0b424b6ce4f · Keenan Nemetz · 18d ago · parent c5626f1b
Phase 1: a working git remote with access control Vole is now a functioning forge foundation: one CGO-free binary that serves real git over smart HTTP and enforces per-repo policy in pre-receive. What is in: - internal/transport drives `git http-backend` as a CGI subprocess. Chunked uploads are spooled to learn CONTENT_LENGTH (git's default client config produces them), spool files are cleaned on error and abort, and a janitor sweeps anything a crash left behind. Pushes are capped by size and serialised behind a semaphore, because Phase 0 measured 1.13 GB of git RSS for a single 1 GiB push. - internal/acl stores roles on refs/vole/acl inside each repository, so access rules travel with a clone. One evaluation function is shared by the hook and the web layer. - internal/hook enforces policy in pre-receive: authentication, write role, protected refs, per-blob size limits, and a refusal to accept pushes to refs/vole/* - without that last check a writer could push their own acl.json and grant themselves admin. - internal/auth holds users, personal access tokens and web sessions in SQLite. Passwords use PBKDF2-SHA256; tokens are 256-bit random values and are stored as SHA-256 hashes, since there is no low-entropy secret to grind. - internal/index is the derived read model, rebuildable from disk and refs. - internal/repo owns the bare-repo store and the metadata ref CAS helper. - internal/web is the browse surface: repo list, tree, blob, raw, history, login, and per-repo settings for access rules, rename and delete. - internal/signing creates the instance ed25519 key at first boot for the Phase 7 work. Every Phase 1 exit criterion has a test that would fail if it regressed: clone/push against a real listener, read-only and anonymous rejection with the ref proven unmoved, rebuild-from-refs compared against the actual acl ref, a spool file proven absent after an aborted chunked upload, and two pushes completing through a single-slot semaphore. scripts/ci.sh runs gofmt, vet, build, a markdown link check and the race-enabled suite. Verified by hand as well: a clean `vole init` -> user -> token -> push -> clone cycle, web login and browse, and a 256 MiB chunked push killed mid-flight leaving no ref, no spool file and no orphaned git processes.
docs/roadmap.md +28−6
| @@ -60,7 +60,7 @@ is to kill bad assumptions while they're cheap. | |||
| 60 | 60 | Everything the roadmap assumed survived contact except one thing: an unsharded | |
| 61 | 61 | metadata ref, which is now off the table permanently. | |
| 62 | 62 | ||
| 63 | ### Phase 1 — It's a git server | ||
| 63 | ### Phase 1 — It's a git server — ✅ complete | ||
| 64 | 64 | ||
| 65 | 65 | The unglamorous foundation. If this phase isn't solid, nothing above it is trustworthy. | |
| 66 | 66 | ||
| @@ -79,11 +79,29 @@ The unglamorous foundation. If this phase isn't solid, nothing above it is trust | |||
| 79 | 79 | - `vole index rebuild` implemented early and exercised by tests, even though the index has | |
| 80 | 80 | little to hold yet | |
| 81 | 81 | ||
| 82 | **Exit criteria:** authenticated clone/push/fetch works against a fresh instance; an | ||
| 83 | unauthorized push is rejected by the hook with an actionable message; `vole index rebuild` | ||
| 84 | reproduces state after deleting the index file; a rejected push leaves no partial ref update; | ||
| 85 | an aborted 1 GiB push leaves no ref and no spooled temp file; a second concurrent 1 GiB push | ||
| 86 | is queued at the semaphore instead of doubling memory. | ||
| 82 | **Exit criteria, and how each is verified:** | ||
| 83 | |||
| 84 | | Criterion | Verified by | | ||
| 85 | |---|---| | ||
| 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:** | ||
| 94 | |||
| 95 | - **`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. | ||
| 87 | 105 | ||
| 88 | 106 | ### Phase 2 — Metadata layer and issues | |
| 89 | 107 | ||
| @@ -245,6 +263,10 @@ Filled in as phases land. Append; don't rewrite. | |||
| 245 | 263 | | 2026-10-22 | Smart HTTP via `git http-backend` subprocess | Spike B: hook stderr propagates, aborts leave no ref and no orphans, 150 MiB/s on compressible data | | |
| 246 | 264 | | 2026-10-22 | Push size cap + concurrency semaphore in Phase 1 | Spike B: 1 GiB push peaked at 1.13 GB git-child RSS | | |
| 247 | 265 | | 2026-10-22 | Static sites served from trees, no export step | Spike C: cold 1.2 ms p50; 170k req/s warm with an 18.5 MiB cache | | |
| 266 | | 2026-10-22 | `pre-receive` is the only policy hook | One enforcement point; all-or-nothing, so a refusal can never half-apply | | ||
| 267 | | 2026-10-22 | Clients may not push `refs/vole/*` | The ACL lives there; allowing it would let a writer self-grant admin | | ||
| 268 | | 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 | | 2026-10-22 | Bad credentials return 401 with a challenge | 403 leaked repo existence and suppressed client retry | | ||
| 248 | 270 | ||
| 249 | 271 | ## Open questions | |
| 250 | 272 | ||