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.

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 }