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.

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}