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.
Accounts with stable ids; the roster moves into the system repository
31df8baea9c9e8acf74b71bdda26d27907b30f2a · Keenan Nemetz · 17d ago · parent 36293228
Accounts with stable ids; the roster moves into the system repository
Names were the primary key everywhere — the keys file name, the ACL entry, the
approval file — so renaming someone broke their access, orphaned their approvals,
and left a retired name able to hand a newcomer the old holder's rights. This
splits identity into an account (stable id, anchored by its SSH keys) and a name
(mutable, never reissued).
data/system.git refs/heads/main
users/<ulid>/meta.json name, status, keys, former_names
Everything that grants authority now holds the id: ACL entries, approval files,
and the identity resolved at the SSH handshake (VOLE_ACTOR_ID). A rename is a
one-file change that disturbs nothing else, and both the old and new names stay
reserved forever.
The roster moved from a trusted-keys directory back into git, which reverses an
earlier simplification — deliberately, and in a smaller shape (one file per
account, one writer, no CAS plumbing for unused fields). Git buys what a directory
could not: `git log` answers who renamed whom and when a key appeared, and the
roster is now readable by anyone who can clone, since it is served read-only at
ssh://host/system.git. `vole request new` and `approve` use exactly that to turn a
name typed on the command line into the account id they store.
Two latent bugs surfaced, both caught by tests added here:
- gitx inherited git's repository-scoped environment. pre-receive runs with
GIT_OBJECT_DIRECTORY and GIT_QUARANTINE_PATH pointing at the pushed repository's
quarantine, so every git call a hook made for a *different* repository silently
read the pushed one: the hook saw an empty roster. gitx now scrubs those
variables and the hook restores them for the pushed repository only.
- The hook built the repository name with filepath.Base on receive-pack's path,
which carries a trailing separator, so refusals read "does not have write access
to .". Cleaned first.
Verified end to end on a live instance: two accounts file, approve and land a
change; `vole user rename wilbur robert` keeps the same key's write access and the
approval still reads as current, now credited to robert; the roster history shows
"user: rename wilbur to robert"; retiring an account rejects its key within five
seconds and refuses to reissue the name.
internal/sshserver/sshserver.go +45−5
| @@ -64,13 +64,14 @@ func New(cfg Config) (*Server, error) { | |||
| 64 | 64 | MaxAuthTries: 6, | |
| 65 | 65 | PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { | |
| 66 | 66 | fp := ssh.FingerprintSHA256(key) | |
| 67 | name, ok := cfg.Identity.Lookup(fp) | ||
| 67 | accountID, name, ok := cfg.Identity.Lookup(fp) | ||
| 68 | 68 | if !ok { | |
| 69 | 69 | cfg.Log.Warn("ssh: unknown key", "fingerprint", fp, "remote", c.RemoteAddr().String()) | |
| 70 | 70 | return nil, fmt.Errorf("key not registered") | |
| 71 | 71 | } | |
| 72 | 72 | return &ssh.Permissions{Extensions: map[string]string{ | |
| 73 | 73 | "user": name, | |
| 74 | "account": accountID, | ||
| 74 | 75 | "fingerprint": fp, | |
| 75 | 76 | }}, nil | |
| 76 | 77 | }, | |
| @@ -123,6 +124,9 @@ func (s *Server) handleConn(c net.Conn) { | |||
| 123 | 124 | go ssh.DiscardRequests(reqs) | |
| 124 | 125 | ||
| 125 | 126 | user := sconn.Permissions.Extensions["user"] | |
| 127 | // The account id is the authorization identity. The name is only ever used | ||
| 128 | // for messages, so renaming somebody cannot change what they may do. | ||
| 129 | accountID := sconn.Permissions.Extensions["account"] | ||
| 126 | 130 | fp := sconn.Permissions.Extensions["fingerprint"] | |
| 127 | 131 | s.cfg.Log.Info("ssh connect", "user", user, "remote", sconn.RemoteAddr().String()) | |
| 128 | 132 | ||
| @@ -135,18 +139,18 @@ func (s *Server) handleConn(c net.Conn) { | |||
| 135 | 139 | if err != nil { | |
| 136 | 140 | continue | |
| 137 | 141 | } | |
| 138 | go s.handleSession(ch, chReqs, user, fp) | ||
| 142 | go s.handleSession(ch, chReqs, user, accountID, fp) | ||
| 139 | 143 | } | |
| 140 | 144 | } | |
| 141 | 145 | ||
| 142 | func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, user, fp string) { | ||
| 146 | func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, user, accountID, fp string) { | ||
| 143 | 147 | defer ch.Close() | |
| 144 | 148 | for req := range reqs { | |
| 145 | 149 | switch req.Type { | |
| 146 | 150 | case "exec": | |
| 147 | 151 | var payload struct{ Command string } | |
| 148 | 152 | ssh.Unmarshal(req.Payload, &payload) | |
| 149 | s.exec(ch, req, payload.Command, user, fp) | ||
| 153 | s.exec(ch, req, payload.Command, user, accountID, fp) | ||
| 150 | 154 | return | |
| 151 | 155 | case "shell", "pty-req", "subsystem": | |
| 152 | 156 | req.Reply(false, nil) | |
| @@ -162,7 +166,7 @@ func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, user, f | |||
| 162 | 166 | } | |
| 163 | 167 | } | |
| 164 | 168 | ||
| 165 | func (s *Server) exec(ch ssh.Channel, req *ssh.Request, command, user, fp string) { | ||
| 169 | func (s *Server) exec(ch ssh.Channel, req *ssh.Request, command, user, accountID, fp string) { | ||
| 166 | 170 | // A refusal is still answered on the channel rather than at the request | |
| 167 | 171 | // layer: replying false makes the client print "exec request failed on | |
| 168 | 172 | // channel 0" and the reason is lost. Nothing is ever executed on these | |
| @@ -194,6 +198,40 @@ func (s *Server) exec(ch ssh.Channel, req *ssh.Request, command, user, fp string | |||
| 194 | 198 | refuse("invalid repository path") | |
| 195 | 199 | return | |
| 196 | 200 | } | |
| 201 | // The instance roster is served read-only at system.git, so a client holding | ||
| 202 | // a clone can read who its accounts are and resolve a name to an account id | ||
| 203 | // without a separate endpoint. It is never writable over SSH: the roster is | ||
| 204 | // changed with `vole user`, by an operator on the instance. | ||
| 205 | if name == "system" || name == "system.git" { | ||
| 206 | if verb != "git-upload-pack" { | ||
| 207 | s.cfg.Log.Warn("ssh: refused write to the system repository", "user", user) | ||
| 208 | refuse("the instance roster is read-only; change it with `vole user` on the server") | ||
| 209 | return | ||
| 210 | } | ||
| 211 | if err := req.Reply(true, nil); err != nil { | ||
| 212 | return | ||
| 213 | } | ||
| 214 | sysDir := s.cfg.Identity.Dir() | ||
| 215 | cmd := exec.Command("git", "upload-pack", sysDir) | ||
| 216 | cmd.Env = append(os.Environ(), "GIT_DIR="+sysDir, "GIT_TERMINAL_PROMPT=0") | ||
| 217 | cmd.Stdin = struct{ io.Reader }{ch} | ||
| 218 | cmd.Stdout = struct{ io.Writer }{ch} | ||
| 219 | cmd.Stderr = struct{ io.Writer }{ch.Stderr()} | ||
| 220 | start := time.Now() | ||
| 221 | runErr := cmd.Run() | ||
| 222 | code := 0 | ||
| 223 | if runErr != nil { | ||
| 224 | code = 1 | ||
| 225 | if ee, ok := runErr.(*exec.ExitError); ok { | ||
| 226 | code = ee.ExitCode() | ||
| 227 | } | ||
| 228 | } | ||
| 229 | s.cfg.Log.Info("ssh git", "user", user, "verb", "upload-pack", | ||
| 230 | "repo", "system", "exit", code, "duration", time.Since(start).String()) | ||
| 231 | exit(ch, code) | ||
| 232 | return | ||
| 233 | } | ||
| 234 | |||
| 197 | 235 | m, err := s.cfg.Repos.Open(name) | |
| 198 | 236 | if err != nil { | |
| 199 | 237 | refuse("repository %q not found", name) | |
| @@ -207,10 +245,12 @@ func (s *Server) exec(ch ssh.Channel, req *ssh.Request, command, user, fp string | |||
| 207 | 245 | cmd.Env = append(os.Environ(), | |
| 208 | 246 | "GIT_DIR="+m.Dir, | |
| 209 | 247 | "VOLE_ACTOR="+user, | |
| 248 | "VOLE_ACTOR_ID="+accountID, | ||
| 210 | 249 | "VOLE_KEY_ID="+fp, | |
| 211 | 250 | "VOLE_REPO="+name, | |
| 212 | 251 | "VOLE_MAX_BLOB="+strconv.FormatInt(s.cfg.MaxBlobBytes, 10), | |
| 213 | 252 | "VOLE_AUDIT="+s.cfg.AuditPath, | |
| 253 | "VOLE_SYSTEM_REPO="+s.cfg.Identity.Dir(), | ||
| 214 | 254 | ) | |
| 215 | 255 | cmd.Stdin = struct{ io.Reader }{ch} | |
| 216 | 256 | cmd.Stdout = struct{ io.Writer }{ch} | |