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.

cmd/vole/runner.go +67−5

@@ -34,6 +34,7 @@ const leaseWait = 25 * time.Second
34 34
35 35 func runnerUsage() string {
36 36 return `usage: vole runner --remote ssh://git@host[:port] [--once] [--dir path] [--key path]
37 [--repos name]...
37 38
38 39 Leases CI jobs from a Vole instance and runs them. Authentication is the SSH key
39 40 you would push with, so there is no token to configure. A runner may only run jobs
@@ -51,7 +52,10 @@ repository's runner allowlist (vole acl runners) cannot report at all.
51 52 --once run one job, then exit (useful for a container that is scheduled per job)
52 53 --dir where to keep checkouts (default: os.UserCacheDir()/vole-runner)
53 54 --key private key to sign results with (default: -i from $VOLE_SSH_COMMAND or
54 $GIT_SSH_COMMAND, else ~/.ssh/id_ed25519)`
55 $GIT_SSH_COMMAND, else ~/.ssh/id_ed25519)
56 --repos only take jobs for these repositories (repeatable). It narrows what the
57 account's runner allowlist grants; it can never add to it. Without it a
58 runner takes any repository it is allowed to run.`
55 59 }
56 60
57 61 type runnerStep struct {
@@ -106,6 +110,9 @@ type runnerArtifact struct {
106 110 type runnerRequest struct {
107 111 Op string `json:"op"`
108 112 Run string `json:"run"`
113 // Repos is the --repos filter, sent with every lease. The server intersects it
114 // with what the account's allowlists grant, so it can only narrow.
115 Repos []string `json:"repos,omitempty"`
109 116 // Wait is how many seconds an idle lease may park before answering "nothing to
110 117 // do"; the server caps it. It is what makes an idle runner cost a parked channel
111 118 // rather than a query every couple of seconds.
@@ -124,6 +131,10 @@ type runnerReply struct {
124 131 Account string `json:"account,omitempty"`
125 132 Name string `json:"name,omitempty"`
126 133 Keys []string `json:"keys,omitempty"`
134 // Repos are the repositories this account may run jobs for. A runner answers the
135 // "ping" with these so a --repos filter can be checked against them at startup:
136 // a filter that matches nothing would otherwise look exactly like a quiet instance.
137 Repos []string `json:"repos,omitempty"`
127 138 }
128 139
129 140 // cmdRunner leases and executes CI jobs. The protocol runs over an SSH channel
@@ -131,7 +142,7 @@ type runnerReply struct {
131 142 // is: no separate credential, nothing stored.
132 143 func cmdRunner(args []string) error {
133 144 f, err := parseFlags(args, flagSpec{long: "remote", short: "r"}, flagSpec{long: "dir"},
134 flagSpec{long: "key"}, flagSpec{long: "once", boolean: true})
145 flagSpec{long: "key"}, flagSpec{long: "repos"}, flagSpec{long: "once", boolean: true})
135 146 if err != nil {
136 147 return err
137 148 }
@@ -180,15 +191,28 @@ func cmdRunner(args []string) error {
180 191 if err := checkSigningKey(who, fingerprint); err != nil {
181 192 return err
182 193 }
194 // --repos narrows the account's grant. Check it against the grant now: a filter that
195 // matches nothing is indistinguishable from a quiet instance, so it would idle
196 // forever while looking healthy. This is the same class of refusal as the key check
197 // above — cheaper to find at startup than after a build that never arrives.
198 if err := checkRepoFilter(f.all("repos"), who); err != nil {
199 return err
200 }
183 201 sess.account, sess.name, sess.fingerprint = who.Account, who.Name, fingerprint
184 fmt.Fprintf(os.Stderr, "vole runner: connected as %s, signing with %s; waiting for work\n",
185 who.Name, fingerprint)
202 sess.repos = f.all("repos")
203 if len(sess.repos) > 0 {
204 fmt.Fprintf(os.Stderr, "vole runner: connected as %s, signing with %s, only taking %s; waiting for work\n",
205 who.Name, fingerprint, strings.Join(sess.repos, ", "))
206 } else {
207 fmt.Fprintf(os.Stderr, "vole runner: connected as %s, signing with %s; waiting for work\n",
208 who.Name, fingerprint)
209 }
186 210 for {
187 211 // A resident runner parks on the server's lease: the reply arrives when work is
188 212 // queued, not on a fixed beat, so an idle pool costs one open channel per
189 213 // runner instead of a query every couple of seconds. A one-shot runner asks
190 214 // once and leaves.
191 req := runnerRequest{Op: "lease"}
215 req := runnerRequest{Op: "lease", Repos: sess.repos}
192 216 if !f.has("once") {
193 217 req.Wait = int(leaseWait.Seconds())
194 218 }
@@ -241,6 +265,44 @@ type runnerSession struct {
241 265 // names it, and the gate refuses a record whose signer is not the pusher.
242 266 account string
243 267 name string
268 // repos is the --repos filter, sent with every lease. It is what the operator
269 // asked for, not the effective list: the server intersects it with the grant on
270 // each request, so an allowlist edit takes effect without a restart.
271 repos []string
272 }
273
274 // checkRepoFilter refuses a --repos filter that can never match. A name the server
275 // did not grant is dropped by the intersection rather than refused — an operator may
276 // reasonably name a repository before it exists — but a filter whose names are *all*
277 // outside the grant is a typo or a permissions problem, and it would otherwise present
278 // as a runner that connected happily and never did anything.
279 func checkRepoFilter(asked []string, who runnerReply) error {
280 if len(asked) == 0 {
281 if len(who.Repos) == 0 {
282 // Not an error: an allowlist nobody has written yet is a normal state, and it
283 // takes effect without a restart. Say so instead of idling silently.
284 fmt.Fprintf(os.Stderr, "vole runner: %s may not run jobs for any repository yet; "+
285 "waiting (an admin allows it with `vole acl runners <repo> --add %s`)\n",
286 who.Name, who.Name)
287 }
288 return nil
289 }
290 granted := make(map[string]bool, len(who.Repos))
291 for _, r := range who.Repos {
292 granted[r] = true
293 }
294 for _, r := range asked {
295 if granted[r] {
296 return nil
297 }
298 }
299 allowed := "none"
300 if len(who.Repos) > 0 {
301 allowed = strings.Join(who.Repos, ", ")
302 }
303 return fmt.Errorf("--repos %s matches nothing %s may run (allowed: %s); "+
304 "a runner can only narrow what its account is allowed",
305 strings.Join(asked, ", "), who.Name, allowed)
244 306 }
245 307
246 308 // checkSigningKey is the refusal that has to happen before work, not after it: the