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.

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)