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.
The roster is a database, not a repository
427c47611f7db09e52618a0f2d05291a8641b2d0 · Keenan Nemetz · 17d ago · parent 0f442b1c
The roster is a database, not a repository data/system.git is gone. Accounts, every name they have held and their SSH keys live in data/users.db, next to the other authoritative store (runs.db) and the disposable index. users id, name, status, created user_names every name ever held -- primary key, so a name is issued once user_keys fingerprint, type, line -- primary key, so a key has one owner Two invariants that used to be parse-time checks are now table keys, and a roster change is one transaction instead of a commit, a CAS retry and a tree walk. The snapshot, the five-second reload and the audit line per change are unchanged, so `vole user add-key` still applies without a restart and revocation is still immediate. Clients no longer clone the roster, because there is nothing to clone. The one view a client needs -- active id and name -- is served by a `vole-roster` SSH verb over the key it already pushes with, so name resolution gained no endpoint and no second credential. `vole request new --author keenan` now resolves through that verb (TestRequestCLIResolvesNamesOverSSH: the request landed with author_id = the account id, and an unknown name fails before pushing). A WAL database cannot be backed up with cp, so `vole user backup <path>` takes a consistent copy with VACUUM INTO (TestRosterIsOneDatabaseFile opens the copy and resolves a renamed account and its key from it). This replaces the "identity survives without a side database" criterion, which is withdrawn rather than left looking unmet: the roster is now exactly one file, and the backup is what proves it is not lost with the index. Verified through the binary: init -> create -> add-key -> rename -> list -> backup, the restored copy listing the same account, former name and key; then scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
internal/sshserver/sshserver.go +16−35
| @@ -215,8 +215,22 @@ func (s *Server) exec(ch ssh.Channel, req *ssh.Request, command, user, accountID | |||
| 215 | 215 | s.serveLFS(ch, req, argv[1], argv[2], user, accountID) | |
| 216 | 216 | return | |
| 217 | 217 | } | |
| 218 | // The client cannot see the server's database, so the roster is served | ||
| 219 | // read-only over the same key-authenticated channel it already uses for git: | ||
| 220 | // one tab-separated id/name line per active account. `vole request` uses it to | ||
| 221 | // turn a typed name into the account id that ACLs and approvals store, and it | ||
| 222 | // is the whole surface — nothing here is writable. | ||
| 223 | if len(argv) == 1 && argv[0] == "vole-roster" { | ||
| 224 | req.Reply(true, nil) | ||
| 225 | for _, a := range s.cfg.Identity.Roster() { | ||
| 226 | fmt.Fprintf(ch, "%s %s\n", a.ID, a.Name) | ||
| 227 | } | ||
| 228 | s.cfg.Log.Info("ssh roster", "user", user, "accounts", s.cfg.Identity.Snapshot().Count) | ||
| 229 | exit(ch, 0) | ||
| 230 | return | ||
| 231 | } | ||
| 218 | 232 | if len(argv) != 2 { | |
| 219 | refuse("only git-upload-pack and git-receive-pack are available") | ||
| 233 | refuse("only git-upload-pack, git-receive-pack and vole-roster are available") | ||
| 220 | 234 | return | |
| 221 | 235 | } | |
| 222 | 236 | verb := argv[0] | |
| @@ -231,39 +245,6 @@ func (s *Server) exec(ch ssh.Channel, req *ssh.Request, command, user, accountID | |||
| 231 | 245 | refuse("invalid repository path") | |
| 232 | 246 | return | |
| 233 | 247 | } | |
| 234 | // The instance roster is served read-only at system.git, so a client holding | ||
| 235 | // a clone can read who its accounts are and resolve a name to an account id | ||
| 236 | // without a separate endpoint. It is never writable over SSH: the roster is | ||
| 237 | // changed with `vole user`, by an operator on the instance. | ||
| 238 | if name == "system" || name == "system.git" { | ||
| 239 | if verb != "git-upload-pack" { | ||
| 240 | s.cfg.Log.Warn("ssh: refused write to the system repository", "user", user) | ||
| 241 | refuse("the instance roster is read-only; change it with `vole user` on the server") | ||
| 242 | return | ||
| 243 | } | ||
| 244 | if err := req.Reply(true, nil); err != nil { | ||
| 245 | return | ||
| 246 | } | ||
| 247 | sysDir := s.cfg.Identity.Dir() | ||
| 248 | cmd := exec.Command("git", "upload-pack", sysDir) | ||
| 249 | cmd.Env = append(os.Environ(), "GIT_DIR="+sysDir, "GIT_TERMINAL_PROMPT=0") | ||
| 250 | cmd.Stdin = struct{ io.Reader }{ch} | ||
| 251 | cmd.Stdout = struct{ io.Writer }{ch} | ||
| 252 | cmd.Stderr = struct{ io.Writer }{ch.Stderr()} | ||
| 253 | start := time.Now() | ||
| 254 | runErr := cmd.Run() | ||
| 255 | code := 0 | ||
| 256 | if runErr != nil { | ||
| 257 | code = 1 | ||
| 258 | if ee, ok := runErr.(*exec.ExitError); ok { | ||
| 259 | code = ee.ExitCode() | ||
| 260 | } | ||
| 261 | } | ||
| 262 | s.cfg.Log.Info("ssh git", "user", user, "verb", "upload-pack", | ||
| 263 | "repo", "system", "exit", code, "duration", time.Since(start).String()) | ||
| 264 | exit(ch, code) | ||
| 265 | return | ||
| 266 | } | ||
| 267 | 248 | ||
| 268 | 249 | m, err := s.cfg.Repos.Open(name) | |
| 269 | 250 | if err != nil { | |
| @@ -286,7 +267,7 @@ func (s *Server) exec(ch ssh.Channel, req *ssh.Request, command, user, accountID | |||
| 286 | 267 | "VOLE_LFS_MAX="+strconv.FormatInt(lfsMax(s.cfg.LFS), 10), | |
| 287 | 268 | "VOLE_LFS_WARN="+strconv.FormatInt(s.cfg.LFSWarnBytes, 10), | |
| 288 | 269 | "VOLE_AUDIT="+s.cfg.AuditPath, | |
| 289 | "VOLE_SYSTEM_REPO="+s.cfg.Identity.Dir(), | ||
| 270 | "VOLE_USERS_DB="+s.cfg.Identity.Path(), | ||
| 290 | 271 | "VOLE_SPOOL="+s.cfg.SpoolDir, | |
| 291 | 272 | "VOLE_RUNS_DB="+s.cfg.RunsDB, | |
| 292 | 273 | ) | |