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.
Phase 4: a runner can narrow what it takes, and cannot widen it
b238ac2448290fb706d0503c2e65798fc618f994 · Keenan Nemetz · 16d ago · parent 8d336083
Phase 4: a runner can narrow what it takes, and cannot widen it `vole runner --repos name` (repeatable) restricts a session to the repositories it names. The restriction is applied by the server as an intersection with what the account's runner allowlist grants, which is the whole design: the flag is a filter, not a grant, so the worst a client can do is run less than it was allowed to. Honouring the client's list directly would have made an SSH flag an access-control decision. An empty filter means "no narrowing" rather than "nothing", which keeps an unfiltered runner following allowlist edits instead of freezing its list at connect time. The filter has a failure mode with no symptom: a runner whose --repos matches nothing connects, waits, and never sees work, which looks exactly like a quiet instance. So "ping" now reports the account's grant and the runner refuses at startup, naming what it asked for and what it was allowed — the same class of check as the signing key, cheaper before a build than after one that never happened. An account with an empty grant and no filter is only a warning, because an allowlist nobody has written yet takes effect without a restart. runs.Store.Lease now takes a LeaseRequest struct. It gained a second list of names, and two adjacent []string arguments are exactly the shape that gets transposed in silence. Measured: TestARequestCannotLeaseBeyondTheAllowlist — a repository the account may *write* to but is not a runner for is refused even when asked for by name, and an admin can still lease it. TestARepoFilterTakesOnlyWhatItNames, TestNarrowCanOnlySubtract, TestRepoFilterThatCanNeverMatchIsRefused and TestARepoFilterOutsideTheGrantIsRefusedAtStartup cover the rest.
internal/sshserver/runner.go +52−2
| @@ -47,6 +47,12 @@ const ( | |||
| 47 | 47 | type runnerRequest struct { | |
| 48 | 48 | Op string `json:"op"` | |
| 49 | 49 | Run string `json:"run"` | |
| 50 | // Repos narrows this session's leases to the repositories the runner was started | ||
| 51 | // for (--repos). It is applied as an intersection with what the account's runner | ||
| 52 | // allowlists grant, so it can only subtract: a runner cannot ask its way into a | ||
| 53 | // repository no allowlist names. Empty means "no narrowing", which keeps the list | ||
| 54 | // following allowlist edits instead of freezing it at connect time. | ||
| 55 | Repos []string `json:"repos,omitempty"` | ||
| 50 | 56 | // Wait is how many seconds an idle lease may park before answering "nothing to | |
| 51 | 57 | // do". Zero answers at once, which is what a one-shot runner wants: it is here to | |
| 52 | 58 | // run the job it was started for, not to wait for the next one. | |
| @@ -110,6 +116,11 @@ type runnerReply struct { | |||
| 110 | 116 | Account string `json:"account,omitempty"` | |
| 111 | 117 | Name string `json:"name,omitempty"` | |
| 112 | 118 | Keys []string `json:"keys,omitempty"` | |
| 119 | // Repos are the repositories this account may run jobs for, sent with "ping" so | ||
| 120 | // a runner started with --repos can find out at startup that its filter matches | ||
| 121 | // nothing — a typo it would otherwise discover as a permanently idle runner — | ||
| 122 | // instead of after the first build that never arrived. | ||
| 123 | Repos []string `json:"repos,omitempty"` | ||
| 113 | 124 | } | |
| 114 | 125 | ||
| 115 | 126 | // serveRunner answers one runner session until it disconnects. | |
| @@ -162,11 +173,16 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) { | |||
| 162 | 173 | } | |
| 163 | 174 | repos, reposAt = list, now | |
| 164 | 175 | } | |
| 176 | // --repos narrows the grant. The intersection is computed here rather than | ||
| 177 | // trusted from the runner, so the worst a hostile client can do is ask for | ||
| 178 | // nothing — never for a repository no allowlist gave it. | ||
| 179 | want := narrow(repos, req.Repos) | ||
| 165 | 180 | // Take the wake-up before asking, so a run queued between the lease and the | |
| 166 | 181 | // wait closes a channel somebody is holding. Asking first and waiting second | |
| 167 | 182 | // loses exactly that race. | |
| 168 | 183 | wake := cfg.Runs.Work() | |
| 169 | run, err := cfg.Runs.Lease(owner, repos, cfg.Lease, cfg.MaxJobsPerRepo, now) | ||
| 184 | lease := runs.LeaseRequest{Owner: owner, Repos: want, TTL: cfg.Lease, MaxPerRepo: cfg.MaxJobsPerRepo} | ||
| 185 | run, err := cfg.Runs.Lease(lease, now) | ||
| 170 | 186 | if err != nil { | |
| 171 | 187 | _ = enc.Encode(runnerReply{Op: "lease", Error: err.Error()}) | |
| 172 | 188 | continue | |
| @@ -182,7 +198,7 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) { | |||
| 182 | 198 | } | |
| 183 | 199 | // Ask again rather than assume: the wake-up may belong to a job another | |
| 184 | 200 | // runner took first, and a lease is the only thing that hands work out. | |
| 185 | run, err = cfg.Runs.Lease(owner, repos, cfg.Lease, cfg.MaxJobsPerRepo, time.Now().UTC()) | ||
| 201 | run, err = cfg.Runs.Lease(lease, time.Now().UTC()) | ||
| 186 | 202 | if err != nil { | |
| 187 | 203 | _ = enc.Encode(runnerReply{Op: "lease", Error: err.Error()}) | |
| 188 | 204 | continue | |
| @@ -345,6 +361,13 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) { | |||
| 345 | 361 | reply.Keys = append(reply.Keys, k.Fingerprint) | |
| 346 | 362 | } | |
| 347 | 363 | } | |
| 364 | // What this account may run, so a runner started with --repos can say at | ||
| 365 | // startup that its filter matches nothing rather than idling in silence. | ||
| 366 | if granted, gerr := s.runnableRepos(accountID); gerr == nil { | ||
| 367 | reply.Repos = granted | ||
| 368 | } else { | ||
| 369 | cfg.Log.Warn("runner: cannot list runnable repositories", "user", user, "err", gerr) | ||
| 370 | } | ||
| 348 | 371 | _ = enc.Encode(reply) | |
| 349 | 372 | ||
| 350 | 373 | default: | |
| @@ -375,3 +398,30 @@ func (s *Server) runnableRepos(accountID string) ([]string, error) { | |||
| 375 | 398 | } | |
| 376 | 399 | return out, nil | |
| 377 | 400 | } | |
| 401 | |||
| 402 | // narrow keeps only the repositories a runner asked for, and is the whole of what | ||
| 403 | // --repos is allowed to do. The granted list is what the account's runner allowlists | ||
| 404 | // produce; the asked list comes from the client. Intersecting them means the worst a | ||
| 405 | // client can do is run less than it was allowed to — the alternative, honouring the | ||
| 406 | // client's list, would make an SSH flag an access-control decision. | ||
| 407 | // | ||
| 408 | // An empty asked list means "no narrowing" rather than "nothing": that is what a runner | ||
| 409 | // without --repos sends, and it keeps such a runner following allowlist edits instead | ||
| 410 | // of freezing its list at connect time. Asked names that were never granted simply | ||
| 411 | // disappear, which the runner reports on startup because it can see both lists. | ||
| 412 | func narrow(granted, asked []string) []string { | ||
| 413 | if len(asked) == 0 { | ||
| 414 | return granted | ||
| 415 | } | ||
| 416 | want := make(map[string]bool, len(asked)) | ||
| 417 | for _, r := range asked { | ||
| 418 | want[r] = true | ||
| 419 | } | ||
| 420 | out := make([]string, 0, len(granted)) | ||
| 421 | for _, r := range granted { | ||
| 422 | if want[r] { | ||
| 423 | out = append(out, r) | ||
| 424 | } | ||
| 425 | } | ||
| 426 | return out | ||
| 427 | } | ||