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.

Replace the git-backed roster with a trusted keys directory

197ecaa29d992ecce2b6078803f3512f565f3672 · Keenan Nemetz · 17d ago · parent a5f19b3c

Replace the git-backed roster with a trusted keys directory

The roster was 476 lines of CAS retries, tree building and snapshot reloading to
store what amounts to username -> authorized_keys lines. Two of the three fields
in meta.json were unused: ACLs key on username, never on id, and nothing ever set
disabled. The git version was paying for history and tamper evidence that
nothing consumed.

Now:

  data/trusted_keys/keenan.keys    # one authorized_keys line per device

This is the AuthorizedKeysFile convention, so ssh-keygen -lf, ansible, rsync and
an editor all work on it, and dropping a key in by hand is a supported way to add
a user because the running instance re-reads the directory every five seconds.
Adds are O_APPEND, so two processes cannot lose each other's writes; removes are
read-modify-rename and assume a single operator. A file that does not parse, or
the same key under two names, still refuses startup rather than silently changing
who can authenticate.

What the directory could not provide on its own is an audit trail, so key changes
append a line to data/audit.log: timestamp, action, user, and key fingerprint.
That is the git log we deleted, at about 15 lines instead of a commit graph. If a
user must be blocked rather than removed, their keys are removed; there is no
disabled flag until something needs one.

Identity is now ~200 lines instead of 476, and the lost-update class of bug that
the CAS loop made possible is gone with the loop.

Verified by hand: fresh init shows data/ only holding audit.log, index.db, repos
and trusted_keys; a key appended to the file by hand starts working within the
poll interval without a restart; removing a key by CLI stops that key
authenticating; audit.log records both.

docs/roadmap.md +9−5

@@ -28,7 +28,7 @@ throughout; see [Non-goals](#non-goals) for what they rule out.
28 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 |
31 | Identity | Roster as `<user>.keys` files in `data/trusted_keys/` | `authorized_keys` format; nothing secret, ops-manageable, and ~280 lines less than a git-backed roster |
32 32 | Web authentication | None: the web is read-only | No password store, no login, no session tokens |
33 33 | Secrets | Instance key + SSH host key, `0600` on disk | A private key in a repo would be cloned and eventually pushed
34 34 | Config | TOML on disk, no database-backed settings | One box, one file, greppable |
@@ -111,10 +111,13 @@ Deviations from the original plan, and why:
111 111 true by construction.
112 112 - **`refs/vole/*` is rejected for client pushes.** The ACL lives there, so
113 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.
114 - **The roster is a trusted-keys directory, not a git repo.** It started on
115 `refs/heads/main` in `data/system.git` (a custom namespace is invisible to
116 `git clone`, so `refs/vole/users` would have produced empty backups). It was
117 then replaced outright by `data/trusted_keys/<user>.keys`: two of the three
118 fields in `meta.json` were unused — ACLs key on username, and nothing ever set
119 `disabled` — so the roster was really just `username -> authorized_keys lines`,
120 which is what sshd has always kept in a file. See the decision log.
118 121 - **No WebAuthn.** It requires a secure context, which would have pulled TLS
119 122 forward from Phase 6 to authenticate a web surface that no longer accepts
120 123 writes.
@@ -289,6 +292,7 @@ Filled in as phases land. Append; don't rewrite.
289 292 | 2026-10-22 | Writes move to SSH; HTTP becomes anonymous read-only | Removes password storage, login, sessions and the token table outright |
290 293 | 2026-10-22 | Web authentication dropped; the web is read-only | Read-only content is exactly what an anonymous principal may see |
291 294 | 2026-10-22 | Identity in `system.git`, roster on `refs/heads/main` | Public keys only; a plain clone is a complete, non-leaking backup |
295 | 2026-10-22 | Roster replaced by a `trusted_keys/` directory | The git-backed version carried unused fields and ~280 lines of CAS/tree machinery; the only real loss was audit, now `data/audit.log` |
292 296 | 2026-10-22 | `receive.maxInputSize` replaces the HTTP byte cap | git enforces it on both transports; the proxy never buffers a pack |
293 297
294 298 ## Open questions