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/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 )