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 | |