Commit ad0bf9770a
Verified · cmc
cmd/gitbay/main.go +5
| @@ -439,6 +439,11 @@ func repoCmd() *cobra.Command { | ||
| 439 | 439 | pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), |
| 440 | 440 | pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}), |
| 441 | 441 | ), |
| 442 | group("runner", "runners attached to a repository", | |
| 443 | pass("add", "attach a runner's public key: < key.pub", passOpts{server: []string{"repo", "runner", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "an SSH public key"}), | |
| 444 | pass("list", "list attached runners", passOpts{server: []string{"repo", "runner", "list"}, needsRepo: true}), | |
| 445 | pass("remove", "detach a runner: <fingerprint>", passOpts{server: []string{"repo", "runner", "remove"}, needsRepo: true}), | |
| 446 | ), | |
| 442 | 447 | group("mirror", "sync with a foreign remote", |
| 443 | 448 | pass("add", "add a mirror: <https-url> --direction push|pull [--username <u>] [--token-stdin]", |
| 444 | 449 | passOpts{server: []string{"repo", "mirror", "add"}, needsRepo: true, stdinOK: true}), |
e2e/buildcancelweb_test.go +6 −8
| @@ -19,19 +19,17 @@ func TestBuildCancelWeb(t *testing.T) { | ||
| 19 | 19 | inst.admin(t, "admin", "user", "create", "alice", |
| 20 | 20 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") |
| 21 | 21 | |
| 22 | // ci is an ordinary account; its runner key is self-added with | |
| 23 | // --scope runner, which confines it to the runner protocol and | |
| 24 | // read-only git rather than reaching for admin. | |
| 25 | ciKey := inst.newKey(t, "ci") | |
| 26 | inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub") | |
| 27 | 22 | runnerKey := inst.newKey(t, "ci-runner") |
| 28 | 23 | pub, _ := os.ReadFile(runnerKey + ".pub") |
| 29 | if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 { | |
| 30 | t.Fatalf("keys add --scope runner: %s", errOut) | |
| 31 | } | |
| 32 | 24 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { |
| 33 | 25 | t.Fatalf("repo create: %s", errOut) |
| 34 | 26 | } |
| 27 | // The runner key is attached by alice through repo runner add, which | |
| 28 | // registers it on her account with scope runner, confining it to the | |
| 29 | // runner protocol and read-only git rather than reaching for admin. | |
| 30 | if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "repo", "runner", "add", "alice/app"); code != 0 { | |
| 31 | t.Fatalf("repo runner add: %s", errOut) | |
| 32 | } | |
| 35 | 33 | work := t.TempDir() |
| 36 | 34 | env := inst.gitEnv(aliceKey) |
| 37 | 35 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") |
internal/control/runnerrepo.go added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | ||
| 8 | "golang.org/x/crypto/ssh" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/policy" | |
| 11 | "gitbay.org/gitbay/internal/protocol" | |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | // Runners attached to a repository (#184). A runner key claims builds only | |
| 16 | // for the repositories it is attached to; a repository admin attaches it | |
| 17 | // by pasting the runner's public key. The key lands on the admin's own | |
| 18 | // account with scope runner, which confines it to the runner protocol and | |
| 19 | // read-only git. | |
| 20 | func init() { | |
| 21 | register(Command{Path: []string{"repo", "runner", "add"}, | |
| 22 | Summary: "attach a runner's public key to a repository", | |
| 23 | Usage: "repo runner add <owner/name> < key.pub", | |
| 24 | ReadsStdin: true, Run: runRepoRunnerAdd}) | |
| 25 | register(Command{Path: []string{"repo", "runner", "list"}, | |
| 26 | Summary: "list the runners attached to a repository", | |
| 27 | Usage: "repo runner list <owner/name>", ReadOnly: true, Run: runRepoRunnerList}) | |
| 28 | register(Command{Path: []string{"repo", "runner", "remove"}, | |
| 29 | Summary: "detach a runner from a repository", | |
| 30 | Usage: "repo runner remove <owner/name> <fingerprint>", Run: runRepoRunnerRemove}) | |
| 31 | } | |
| 32 | ||
| 33 | func runRepoRunnerAdd(c *Ctx, args []string) int { | |
| 34 | f, err := parseFlags(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"}) | |
| 35 | if err != nil || len(f.Pos) != 1 { | |
| 36 | return c.fail(protocol.ExitUsage, "usage: repo runner add <owner/name> < key.pub") | |
| 37 | } | |
| 38 | repo, code := resolveRepo(c, f.Pos[0], policy.CanAdmin) | |
| 39 | if code >= 0 { | |
| 40 | return code | |
| 41 | } | |
| 42 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) | |
| 43 | if err != nil { | |
| 44 | return c.fail(protocol.ExitFailure, "reading key: %v", err) | |
| 45 | } | |
| 46 | pub, _, _, _, err := ssh.ParseAuthorizedKey(raw) | |
| 47 | if err != nil { | |
| 48 | return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err) | |
| 49 | } | |
| 50 | fp := ssh.FingerprintSHA256(pub) | |
| 51 | key, err := c.Store.SSHKeyByFingerprint(fp) | |
| 52 | switch { | |
| 53 | case errors.Is(err, store.ErrNotFound): | |
| 54 | if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner"); err != nil { | |
| 55 | return c.fail(protocol.ExitFailure, "adding key: %v", err) | |
| 56 | } | |
| 57 | if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil { | |
| 58 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 59 | } | |
| 60 | case err != nil: | |
| 61 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 62 | case key.Scope != "runner": | |
| 63 | // A full key would let a build step administer the account; a | |
| 64 | // deploy key is bound elsewhere. A runner gets a key of its own. | |
| 65 | return c.fail(protocol.ExitDenied, "%s is a %s key, not a runner key; give the runner a key of its own", fp, key.Scope) | |
| 66 | case key.UserID != c.User.ID && !c.User.IsAdmin: | |
| 67 | return c.fail(protocol.ExitDenied, "%s belongs to another account", fp) | |
| 68 | } | |
| 69 | if err := c.Store.AttachRunner(key.ID, repo.ID); err != nil { | |
| 70 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 71 | } | |
| 72 | c.Store.Audit(c.User.ID, "repo.runner.add", map[string]any{"repo": repo.Path(), "fingerprint": fp}) | |
| 73 | d := map[string]string{"fingerprint": fp, "repo": repo.Path()} | |
| 74 | return c.emit(d, func(w io.Writer) { | |
| 75 | fmt.Fprintf(w, "runner %s attached to %s\n", fp, repo.Path()) | |
| 76 | }) | |
| 77 | } | |
| 78 | ||
| 79 | func runRepoRunnerList(c *Ctx, args []string) int { | |
| 80 | if len(args) != 1 { | |
| 81 | return c.fail(protocol.ExitUsage, "usage: repo runner list <owner/name>") | |
| 82 | } | |
| 83 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 84 | if code >= 0 { | |
| 85 | return code | |
| 86 | } | |
| 87 | runners, err := c.Store.ListRepoRunners(repo.ID) | |
| 88 | if err != nil { | |
| 89 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 90 | } | |
| 91 | if runners == nil { | |
| 92 | runners = []store.RepoRunner{} | |
| 93 | } | |
| 94 | return c.emit(runners, func(w io.Writer) { | |
| 95 | for _, r := range runners { | |
| 96 | seen := r.LastSeen | |
| 97 | if seen == "" { | |
| 98 | seen = "never" | |
| 99 | } | |
| 100 | held := "idle" | |
| 101 | if r.BuildNumber != 0 { | |
| 102 | held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) | |
| 103 | } | |
| 104 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Fingerprint, r.Algo, r.Username, seen, held) | |
| 105 | } | |
| 106 | }) | |
| 107 | } | |
| 108 | ||
| 109 | func runRepoRunnerRemove(c *Ctx, args []string) int { | |
| 110 | if len(args) != 2 { | |
| 111 | return c.fail(protocol.ExitUsage, "usage: repo runner remove <owner/name> <fingerprint>") | |
| 112 | } | |
| 113 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 114 | if code >= 0 { | |
| 115 | return code | |
| 116 | } | |
| 117 | if err := c.Store.DetachRunner(repo.ID, args[1]); err != nil { | |
| 118 | if errors.Is(err, store.ErrNotFound) { | |
| 119 | return c.fail(protocol.ExitNotFound, "no runner %s on %s", args[1], repo.Path()) | |
| 120 | } | |
| 121 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 122 | } | |
| 123 | c.Store.Audit(c.User.ID, "repo.runner.remove", map[string]any{"repo": repo.Path(), "fingerprint": args[1]}) | |
| 124 | return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) { | |
| 125 | fmt.Fprintf(w, "runner %s detached from %s\n", args[1], repo.Path()) | |
| 126 | }) | |
| 127 | } | |
internal/control/runnerrepo_test.go added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/config" | |
| 9 | "gitbay.org/gitbay/internal/protocol" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | // Generated once with ssh-keygen -t ed25519; a valid authorized_keys line. | |
| 14 | const testRunnerPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILAr2r82jFsCJwsEyrEf2wgKy9Dv45xYYici6Ii7NyCS runner@test\n" | |
| 15 | ||
| 16 | func repoRunnerCtx(t *testing.T, st *store.Store, uid int64, admin bool, stdin string) (*Ctx, *bytes.Buffer) { | |
| 17 | t.Helper() | |
| 18 | var out bytes.Buffer | |
| 19 | return &Ctx{ | |
| 20 | User: store.User{ID: uid, Username: "alice", IsAdmin: admin}, | |
| 21 | Scope: "full", | |
| 22 | Source: "SHA256:session", | |
| 23 | Store: st, | |
| 24 | Cfg: config.Config{Server: config.Server{SiteURL: "https://x.test"}}, | |
| 25 | Stdin: strings.NewReader(stdin), | |
| 26 | Stdout: &out, | |
| 27 | Stderr: &out, | |
| 28 | JSON: true, | |
| 29 | }, &out | |
| 30 | } | |
| 31 | ||
| 32 | // A fresh key is registered on the caller's account with scope runner and | |
| 33 | // attached; a second add is a no-op; list shows it; remove detaches and | |
| 34 | // leaves the key on the account. | |
| 35 | func TestRepoRunnerAddListRemove(t *testing.T) { | |
| 36 | st, repo, uid := newQueueTestRepo(t) | |
| 37 | c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 38 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { | |
| 39 | t.Fatalf("add: exit %d %s", code, out.String()) | |
| 40 | } | |
| 41 | if !strings.Contains(out.String(), `"fingerprint":"SHA256:`) { | |
| 42 | t.Fatalf("add output: %s", out.String()) | |
| 43 | } | |
| 44 | keys, _ := st.ListSSHKeys(uid) | |
| 45 | if len(keys) != 1 || keys[0].Scope != "runner" { | |
| 46 | t.Fatalf("key not registered as runner: %+v", keys) | |
| 47 | } | |
| 48 | fp := keys[0].Fingerprint | |
| 49 | c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 50 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { | |
| 51 | t.Fatalf("second add: exit %d %s", code, out.String()) | |
| 52 | } | |
| 53 | c, out = repoRunnerCtx(t, st, uid, false, "") | |
| 54 | if code := runRepoRunnerList(c, []string{repo.Path()}); code != protocol.ExitOK || strings.Count(out.String(), fp) != 1 { | |
| 55 | t.Fatalf("list: exit %d %s", code, out.String()) | |
| 56 | } | |
| 57 | c, out = repoRunnerCtx(t, st, uid, false, "") | |
| 58 | if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitOK { | |
| 59 | t.Fatalf("remove: exit %d %s", code, out.String()) | |
| 60 | } | |
| 61 | if ok, _ := st.RunnerAttached(keys[0].ID, repo.ID); ok { | |
| 62 | t.Fatal("still attached after remove") | |
| 63 | } | |
| 64 | if keys, _ = st.ListSSHKeys(uid); len(keys) != 1 { | |
| 65 | t.Fatal("remove dropped the key from the account") | |
| 66 | } | |
| 67 | c, out = repoRunnerCtx(t, st, uid, false, "") | |
| 68 | if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitNotFound { | |
| 69 | t.Fatalf("remove twice: exit %d, want %d", code, protocol.ExitNotFound) | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | // A key that already exists with another scope is never promoted, and | |
| 74 | // another account's runner key is refused unless the caller is an admin. | |
| 75 | func TestRepoRunnerAddRefusesWrongKeys(t *testing.T) { | |
| 76 | st, repo, uid := newQueueTestRepo(t) | |
| 77 | c, _ := repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 78 | // Register the same key as a full key first. | |
| 79 | if code := runKeysAdd(c, nil); code != protocol.ExitOK { | |
| 80 | t.Fatal("keys add failed") | |
| 81 | } | |
| 82 | c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 83 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied { | |
| 84 | t.Fatalf("full key accepted as runner: exit %d %s", code, out.String()) | |
| 85 | } | |
| 86 | keys, _ := st.ListSSHKeys(uid) | |
| 87 | if keys[0].Scope != "full" { | |
| 88 | t.Fatalf("scope changed to %s", keys[0].Scope) | |
| 89 | } | |
| 90 | // Someone else's runner key. | |
| 91 | bob, _ := st.CreateUser("bob", false) | |
| 92 | if err := st.AddSSHKey(bob, "SHA256:bobrunner", "ssh-ed25519", []byte("x"), "runner"); err != nil { | |
| 93 | t.Fatal(err) | |
| 94 | } | |
| 95 | st.RemoveSSHKey(uid, keys[0].Fingerprint) | |
| 96 | if err := st.AddSSHKey(bob, keys[0].Fingerprint, "ssh-ed25519", keys[0].Blob, "runner"); err != nil { | |
| 97 | t.Fatal(err) | |
| 98 | } | |
| 99 | c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub) | |
| 100 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied { | |
| 101 | t.Fatalf("another account's key attached by a non-admin: exit %d %s", code, out.String()) | |
| 102 | } | |
| 103 | c, out = repoRunnerCtx(t, st, uid, true, testRunnerPub) | |
| 104 | if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK { | |
| 105 | t.Fatalf("admin could not attach another account's runner key: exit %d %s", code, out.String()) | |
| 106 | } | |
| 107 | } | |