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.
Give a job a credential for the bot it acts as
6e1fca5115b709d31eeb51864b187d79aa104e5f · Keenan Nemetz · 6d ago · parent 446542e6
Give a job a credential for the bot it acts as A workflow can name a bot account with `bot:`. When the job is leased the instance generates a key for that run, stores the public half with the run, repository and expiry, and sends the private half on the lease channel. The runner writes it into the job's run directory with a known_hosts entry for the instance, so a step's `git push` or `vole issue comment` authenticates as the account and nothing has to be wired per job. The account's role is the permission, in full. The credential replaces exactly one requirement — that writing to the issues branch needs `maintain` — and adds no rule of its own. What a job may write there is what its account may do, asked the same way the browser asks: discuss, file an issue, manage an item it wrote, open a change from a branch it may push, triage and approve when the role allows it. Those are the ACL's own capabilities (`CanDiscuss`, `CanOpenRequest`, `CanTriage`, `CanApprove`), so a maintainer's job may do everything a maintainer's key may do, including labels, states, and other people's items. A session is scoped only when the fingerprint it authenticated with is a live credential row; an expired one reads as absent, so the push falls back to the account's own role and is refused where a contribute account cannot write. Two things end the key: the run finishing, which drops the row, and the expiry, which is what stops a credential whose runner was killed and never reported. The key is also masked in the log like a secret, since a step that cats it should not leave it in the attempt. Closes #165.
server/ssh/server.go +42−4
| @@ -128,6 +128,10 @@ type Server struct { | |||
| 128 | 128 | // also the wait. | |
| 129 | 129 | conns chan struct{} | |
| 130 | 130 | gitSlots chan struct{} | |
| 131 | // hostKey is this instance's public host key as one authorized_keys line. It | ||
| 132 | // travels with a lease so a job that acts as a bot can write the known_hosts | ||
| 133 | // entry it needs without trusting whatever answers a scan. | ||
| 134 | hostKey string | ||
| 131 | 135 | ||
| 132 | 136 | // refused throttles the "listener is full" warning. A flood is exactly when the | |
| 133 | 137 | // log must not become the next unbounded resource. | |
| @@ -179,6 +183,7 @@ func New(cfg Config) (*Server, error) { | |||
| 179 | 183 | cfg: cfg, | |
| 180 | 184 | conns: make(chan struct{}, cfg.maxConnections()), | |
| 181 | 185 | gitSlots: make(chan struct{}, cfg.maxGitProcesses()), | |
| 186 | hostKey: strings.TrimSpace(string(xssh.MarshalAuthorizedKey(signer.PublicKey()))), | ||
| 182 | 187 | } | |
| 183 | 188 | s.sshCfg = &xssh.ServerConfig{ | |
| 184 | 189 | ServerVersion: serverVersion, | |
| @@ -186,14 +191,27 @@ func New(cfg Config) (*Server, error) { | |||
| 186 | 191 | PublicKeyCallback: func(c xssh.ConnMetadata, key xssh.PublicKey) (*xssh.Permissions, error) { | |
| 187 | 192 | fp := xssh.FingerprintSHA256(key) | |
| 188 | 193 | name, ok := cfg.Identity.Lookup(fp) | |
| 194 | scoped := false | ||
| 195 | if !ok && cfg.Runs != nil { | ||
| 196 | // A run credential is a key for one job. It resolves to the account the | ||
| 197 | // job acts as, but the session it opens is marked, because a job may do | ||
| 198 | // less than its account: it may write as that account, not administer it. | ||
| 199 | cred, err := cfg.Runs.RunCredential(fp, time.Now().UTC()) | ||
| 200 | if err == nil && cred != nil { | ||
| 201 | if acct := cfg.Identity.Snapshot().ByName[cred.Account]; acct != nil && acct.Active() { | ||
| 202 | name, ok, scoped = cred.Account, true, true | ||
| 203 | } | ||
| 204 | } | ||
| 205 | } | ||
| 189 | 206 | if !ok { | |
| 190 | 207 | cfg.Log.Warn("ssh: unknown key", "fingerprint", fp, "remote", c.RemoteAddr().String()) | |
| 191 | 208 | return nil, fmt.Errorf("key not registered") | |
| 192 | 209 | } | |
| 193 | return &xssh.Permissions{Extensions: map[string]string{ | ||
| 194 | "user": name, | ||
| 195 | "fingerprint": fp, | ||
| 196 | }}, nil | ||
| 210 | ext := map[string]string{"user": name, "fingerprint": fp} | ||
| 211 | if scoped { | ||
| 212 | ext["credential"] = fp | ||
| 213 | } | ||
| 214 | return &xssh.Permissions{Extensions: ext}, nil | ||
| 197 | 215 | }, | |
| 198 | 216 | } | |
| 199 | 217 | s.sshCfg.AddHostKey(signer) | |
| @@ -384,6 +402,20 @@ func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, rem | |||
| 384 | 402 | refuse("malformed command") | |
| 385 | 403 | return | |
| 386 | 404 | } | |
| 405 | // A run credential is a key for one job, and the job may do less than its account: | ||
| 406 | // push and fetch for its own repository, and ask who it is. Every other verb — | ||
| 407 | // administering the instance, reading secrets, leasing work, landing a change — is | ||
| 408 | // an account capability, and a credential that could reach them would be a | ||
| 409 | // long-lived key with extra steps. | ||
| 410 | cred, credErr := s.cfg.Runs.RunCredential(fp, time.Now().UTC()) | ||
| 411 | if credErr != nil { | ||
| 412 | s.cfg.Log.Warn("ssh: cannot read a run credential", "fingerprint", fp, "err", credErr) | ||
| 413 | } | ||
| 414 | if cred != nil && !(len(argv) == 1 && argv[0] == "vole-whoami") && | ||
| 415 | !(len(argv) == 2 && (argv[0] == "git-upload-pack" || argv[0] == "git-receive-pack")) { | ||
| 416 | refuse("a job credential may only push, fetch, and ask who it is") | ||
| 417 | return | ||
| 418 | } | ||
| 387 | 419 | // The CI runner is the one non-git verb. It reuses the same key identity rather | |
| 388 | 420 | // than introducing a token, and it is a stream: the runner leases jobs and | |
| 389 | 421 | // reports results over this channel. | |
| @@ -495,6 +527,12 @@ func (s *Server) exec(ch xssh.Channel, req *xssh.Request, command, user, fp, rem | |||
| 495 | 527 | return | |
| 496 | 528 | } | |
| 497 | 529 | ||
| 530 | // A credential names one repository. Anything else is a job reaching outside the | ||
| 531 | // work it was given, even if its account could reach further. | ||
| 532 | if cred != nil && cred.Repo != name { | ||
| 533 | refuse("this job's credential is for %q, not %q", cred.Repo, name) | ||
| 534 | return | ||
| 535 | } | ||
| 498 | 536 | m, err := s.cfg.Repos.Open(name) | |
| 499 | 537 | if err != nil { | |
| 500 | 538 | refuse("repository %q not found", name) | |