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/server/integration_test.go +71−11
| @@ -83,11 +83,11 @@ func newFixtureWith(t *testing.T, tweak func(*config.Config)) *fixture { | |||
| 83 | 83 | if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil { | |
| 84 | 84 | t.Fatal(err) | |
| 85 | 85 | } | |
| 86 | if err := identity.Init(cfg.SystemRepo()); err != nil { | ||
| 86 | if err := identity.Init(cfg.UsersDB()); err != nil { | ||
| 87 | 87 | t.Fatal(err) | |
| 88 | 88 | } | |
| 89 | 89 | pub, priv := keypair(t, filepath.Join(cfg.DataDir, "id_test")) | |
| 90 | ident, err := identity.Open(cfg.SystemRepo()) | ||
| 90 | ident, err := identity.Open(cfg.UsersDB()) | ||
| 91 | 91 | if err != nil { | |
| 92 | 92 | t.Fatal(err) | |
| 93 | 93 | } | |
| @@ -427,10 +427,10 @@ func TestRebuildIndexAfterDelete(t *testing.T) { | |||
| 427 | 427 | } | |
| 428 | 428 | } | |
| 429 | 429 | ||
| 430 | // TestRosterDirectoryIsTheWholeIdentity proves identity is nothing but a | ||
| 431 | // directory of public keys: copy it anywhere and the roster is complete, with no | ||
| 432 | // database and no side repository. | ||
| 433 | func TestRosterDirectoryIsTheWholeIdentity(t *testing.T) { | ||
| 430 | // TestRosterIsOneDatabaseFile proves the backup story end to end: the roster is | ||
| 431 | // one SQLite file, a consistent copy of it is complete, and no system repository | ||
| 432 | // or key directory exists beside it. | ||
| 433 | func TestRosterIsOneDatabaseFile(t *testing.T) { | ||
| 434 | 434 | f := newFixture(t) | |
| 435 | 435 | f.publish(f.repo) | |
| 436 | 436 | work := workRepo(t, map[string]string{"readme.md": "x\n"}) | |
| @@ -438,14 +438,15 @@ func TestRosterDirectoryIsTheWholeIdentity(t *testing.T) { | |||
| 438 | 438 | t.Fatalf("push: %s", errs) | |
| 439 | 439 | } | |
| 440 | 440 | ||
| 441 | copied := filepath.Join(t.TempDir(), "trusted_keys") | ||
| 442 | if out, err := exec.Command("cp", "-r", f.cfg.SystemRepo(), copied).CombinedOutput(); err != nil { | ||
| 443 | t.Fatalf("copy roster: %v: %s", err, out) | ||
| 441 | copied := filepath.Join(t.TempDir(), "users.db") | ||
| 442 | if err := f.srv.Identity.Backup(copied); err != nil { | ||
| 443 | t.Fatal(err) | ||
| 444 | 444 | } | |
| 445 | 445 | ident, err := identity.Open(copied) | |
| 446 | 446 | if err != nil { | |
| 447 | 447 | t.Fatal(err) | |
| 448 | 448 | } | |
| 449 | defer ident.Close() | ||
| 449 | 450 | if _, ok := ident.Snapshot().ByName["keenan"]; !ok { | |
| 450 | 451 | t.Fatal("copied roster has no users") | |
| 451 | 452 | } | |
| @@ -453,9 +454,9 @@ func TestRosterDirectoryIsTheWholeIdentity(t *testing.T) { | |||
| 453 | 454 | t.Fatalf("copied roster has %d keys", len(ident.Snapshot().ByKey)) | |
| 454 | 455 | } | |
| 455 | 456 | ||
| 456 | for _, absent := range []string{"auth.db", "trusted_keys"} { | ||
| 457 | for _, absent := range []string{"auth.db", "system.git", "trusted_keys"} { | ||
| 457 | 458 | if _, err := os.Stat(filepath.Join(f.cfg.DataDir, absent)); err == nil { | |
| 458 | t.Fatalf("%s exists; identity is supposed to be a keys directory only", absent) | ||
| 459 | t.Fatalf("%s exists; the roster is supposed to be one database file", absent) | ||
| 459 | 460 | } | |
| 460 | 461 | } | |
| 461 | 462 | } | |
| @@ -758,6 +759,65 @@ func TestIssueCLIEndToEnd(t *testing.T) { | |||
| 758 | 759 | } | |
| 759 | 760 | } | |
| 760 | 761 | ||
| 762 | // TestRequestCLIResolvesNamesOverSSH: the roster is server-side SQLite, so a clone | ||
| 763 | // has no copy of it to read. `vole request new --author <name>` has to ask the | ||
| 764 | // instance over the SSH channel it pushes on, and the request it writes must carry | ||
| 765 | // the account id the server authenticated rather than the name it was given. | ||
| 766 | func TestRequestCLIResolvesNamesOverSSH(t *testing.T) { | ||
| 767 | f := newFixture(t) | ||
| 768 | f.publish(f.repo) | ||
| 769 | work := workRepo(t, map[string]string{"readme.md": "hi\n"}) | ||
| 770 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 771 | t.Fatalf("push main: %s", errs) | ||
| 772 | } | ||
| 773 | clone := filepath.Join(t.TempDir(), "clone") | ||
| 774 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil { | ||
| 775 | t.Fatalf("clone: %s", errs) | ||
| 776 | } | ||
| 777 | |||
| 778 | run := func(args ...string) (string, string, error) { | ||
| 779 | cmd := exec.Command(binPath, args...) | ||
| 780 | cmd.Dir = clone | ||
| 781 | cmd.Env = append(os.Environ(), | ||
| 782 | "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR", | ||
| 783 | "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", | ||
| 784 | "USER=keenan") | ||
| 785 | var out, errb bytes.Buffer | ||
| 786 | cmd.Stdout, cmd.Stderr = &out, &errb | ||
| 787 | err := cmd.Run() | ||
| 788 | return out.String(), errb.String(), err | ||
| 789 | } | ||
| 790 | |||
| 791 | // A name the roster does not know fails before anything is pushed. | ||
| 792 | if _, errs, err := run("request", "new", "--title", "nobody", "-m", "x", "--author", "nobody"); err == nil { | ||
| 793 | t.Fatalf("an unknown author was accepted: %s", errs) | ||
| 794 | } | ||
| 795 | |||
| 796 | out, errs, err := run("request", "new", "--title", "Roster over SSH", "-m", "body", "--author", "keenan") | ||
| 797 | if err != nil { | ||
| 798 | t.Fatalf("request new: %v\n%s", err, errs) | ||
| 799 | } | ||
| 800 | fields := strings.Fields(out) | ||
| 801 | if len(fields) == 0 { | ||
| 802 | t.Fatalf("no request id printed: %q", out) | ||
| 803 | } | ||
| 804 | id := fields[0] | ||
| 805 | |||
| 806 | // What landed on the server names the account id, so the name was resolved | ||
| 807 | // from the roster rather than passed through. | ||
| 808 | raw, _, err := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | ||
| 809 | "show", "refs/heads/vole:requests/"+id+"/meta.json") | ||
| 810 | if err != nil { | ||
| 811 | t.Fatalf("read request from the server: %v\n%s", err, raw) | ||
| 812 | } | ||
| 813 | if !strings.Contains(raw, `"author": "keenan"`) { | ||
| 814 | t.Fatalf("request lost the author name:\n%s", raw) | ||
| 815 | } | ||
| 816 | if !strings.Contains(raw, `"author_id": "`+f.ownerID+`"`) { | ||
| 817 | t.Fatalf("request author_id is not the account id %s:\n%s", f.ownerID, raw) | ||
| 818 | } | ||
| 819 | } | ||
| 820 | |||
| 761 | 821 | func (f *fixture) serverIssues(t *testing.T) string { | |
| 762 | 822 | t.Helper() | |
| 763 | 823 | out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | |