webhook add reads the secret from stdin; runner docs name real attachments !510

merged merged by cmc on 2026-09-29 00:19 UTC · krz/gitbay:webhook-secret-stdin into main

9 files changed, +105 −23

Layout: unified · split

.gitbay/wiki/API.org +5 −1
@@ -150,13 +150,17 @@ Per-repository outbound POSTs for repository events. Managed by repo
150admins: 150admins:
151 151
152#+begin_src sh 152#+begin_src sh
153gitbay webhook add <url> --secret s3cret [--events push,issue.created] # default * 153printf %s "$SECRET" | gitbay webhook add <url> --secret - [--events push,issue.created] # default *
154gitbay webhook list 154gitbay webhook list
155gitbay webhook deliveries [--limit 50] # status, attempts, last error 155gitbay webhook deliveries [--limit 50] # status, attempts, last error
156gitbay webhook redeliver <delivery-id> # requeue, including dead letters 156gitbay webhook redeliver <delivery-id> # requeue, including dead letters
157gitbay webhook remove <id> 157gitbay webhook remove <id>
158#+end_src 158#+end_src
159 159
160The signing secret is read from stdin with =--secret -=; a value on the
161command line is refused, since argv shows in process listings and shell
162history. Over the JSON API it goes in the request's =stdin= field.
163
160** Events 164** Events
161 165
162Every event this forge records, and so every name =--events= may take. 166Every event this forge records, and so every name =--events= may take.
.gitbay/wiki/Admin.org +10 −9
@@ -925,16 +925,19 @@ at real ones.* Every deploy that switched the whole instance to
925containers and failed took CI down with it. Instead: create a throwaway 925containers and failed took CI down with it. Instead: create a throwaway
926repository the runner account can read (public, or granted read — a 926repository the runner account can read (public, or granted read — a
927private one is "not found" to the runner and the build stays pending), 927private one is "not found" to the runner and the build stays pending),
928give it one job that names the CI image, and deploy the runner with 928give it one job that names the CI image, attach the runner's key to it,
929=-repos= naming only that repository. The production unit, with its real 929and deploy the runner with =-repos= naming only that repository. The
930hardening, then claims nothing else; other repositories' builds queue 930production unit, with its real hardening, then claims nothing else;
931until =-repos= is switched back, which is a pause, not an outage. 931other repositories' builds queue until =-repos= is removed again, which
932is a pause, not an outage.
932 933
933#+begin_src sh 934#+begin_src sh
934gitbay repo create cmc/ci-smoke # then push a .gitbay/ci.yml naming the image 935gitbay repo create cmc/runner-scratch # then push a .gitbay/ci.yml naming the image
935sed -i 's#-repos krz/gitbay #-repos cmc/ci-smoke #' /etc/systemd/system/gitbay-runner.service.d/override.conf 936# on the host:
937gitbay repo runner add cmc/runner-scratch < /var/lib/gitbay-runner/.ssh/id_ed25519.pub
938sed -i 's#^ExecStart=/usr/local/bin/gitbay-runner #&-repos cmc/runner-scratch #' /etc/systemd/system/gitbay-runner.service.d/override.conf
936systemctl daemon-reload && systemctl restart gitbay-runner 939systemctl daemon-reload && systemctl restart gitbay-runner
937gitbay build log cmc/ci-smoke 1 # green: switch -repos back, redeploy 940gitbay build log cmc/runner-scratch 1 # green: remove -repos, redeploy, delete the scratch repository
938#+end_src 941#+end_src
939 942
940*Do not deploy an isolating runner to a host that has not been 943*Do not deploy an isolating runner to a host that has not been
@@ -950,8 +953,6 @@ management and =ReadWritePaths= for podman's store under
950make read-only. Those paths are prefixed =-= so they are ignored when 953make read-only. Those paths are prefixed =-= so they are ignored when
951absent: the drop-in installs on unprepared hosts too, and a unit that 954absent: the drop-in installs on unprepared hosts too, and a unit that
952refused to start would stop every build. 955refused to start would stop every build.
953The nightly canary on =cmc/ci-smoke= only runs if the runner's =-repos=
954names that repository too; a scoped runner claims nothing else.
955=gitbay-runner-prune.timer= prunes unused images weekly, as the runner's 956=gitbay-runner-prune.timer= prunes unused images weekly, as the runner's
956user: rootless storage belongs to that user, and root's prune would not 957user: rootless storage belongs to that user, and root's prune would not
957see it. An unpruned image store on a 40GB host is a slow outage. 958see it. An unpruned image store on a 40GB host is a slow outage.
CHANGELOG.org +5
@@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased 7* Unreleased
8 8
9- =webhook add= reads the signing secret from stdin with =--secret -=;
10 a value on the command line is refused, since argv shows in process
11 listings and shell history. A script that passed the value must pipe
12 it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -=
13 (#284).
9- The builds page's status badge section gives an org-mode snippet 14- The builds page's status badge section gives an org-mode snippet
10 beside the Markdown one, for a README.org (#299). 15 beside the Markdown one, for a README.org (#299).
11- API tokens on the settings page: create with a scope and optional 16- API tokens on the settings page: create with a scope and optional
cmd/gitbay/main.go +2 −2
@@ -357,7 +357,7 @@ func isEmptyReader(r io.Reader) bool {
357// usesStdin reports whether the arguments request stdin content. 357// usesStdin reports whether the arguments request stdin content.
358func usesStdin(args []string) bool { 358func usesStdin(args []string) bool {
359 for i, a := range args { 359 for i, a := range args {
360 if (a == "--file" || a == "--key") && i+1 < len(args) && args[i+1] == "-" { 360 if (a == "--file" || a == "--key" || a == "--secret") && i+1 < len(args) && args[i+1] == "-" {
361 return true 361 return true
362 } 362 }
363 if a == "--token-stdin" { 363 if a == "--token-stdin" {
@@ -750,7 +750,7 @@ func webCmd() *cobra.Command {
750 750
751func webhookCmd() *cobra.Command { 751func webhookCmd() *cobra.Command {
752 return group("webhook", "outbound event delivery", 752 return group("webhook", "outbound event delivery",
753 pass("add", passOpts{server: []string{"webhook", "add"}, needsRepo: true}), 753 pass("add", passOpts{server: []string{"webhook", "add"}, needsRepo: true, stdinOK: true, stdinWhat: "the webhook secret", stdinSecret: true}),
754 pass("list", passOpts{server: []string{"webhook", "list"}, needsRepo: true}), 754 pass("list", passOpts{server: []string{"webhook", "list"}, needsRepo: true}),
755 pass("remove", passOpts{server: []string{"webhook", "remove"}, needsRepo: true}), 755 pass("remove", passOpts{server: []string{"webhook", "remove"}, needsRepo: true}),
756 pass("deliveries", passOpts{server: []string{"webhook", "deliveries"}, needsRepo: true}), 756 pass("deliveries", passOpts{server: []string{"webhook", "deliveries"}, needsRepo: true}),
cmd/gitbay/stdinpayload_test.go +11
@@ -114,3 +114,14 @@ func swapTerminal(f func(*os.File) bool) func() {
114 isTerminal = f 114 isTerminal = f
115 return func() { isTerminal = prev } 115 return func() { isTerminal = prev }
116} 116}
117
118// webhook add --secret - reads the secret on the server, so the CLI must
119// forward stdin for it the way it does for --file - (#284).
120func TestUsesStdinForSecretDash(t *testing.T) {
121 if !usesStdin([]string{"alice/app", "https://ci.example/hook", "--secret", "-"}) {
122 t.Error("--secret - does not forward stdin")
123 }
124 if usesStdin([]string{"alice/app", "https://ci.example/hook", "--events", "push"}) {
125 t.Error("forwarded stdin with no flag asking for it")
126 }
127}
deploy/gitbay-runner.override.conf +5 −3
@@ -34,9 +34,11 @@ After=gitbay-runner-egress.service
34[Service] 34[Service]
35# The runner polls as a non-admin account with a runner-scoped key, and 35# The runner polls as a non-admin account with a runner-scoped key, and
36# claims only the repositories that key is attached to (`repo runner 36# claims only the repositories that key is attached to (`repo runner
37# add`): krz/gitbay and cmc/ci-smoke. The attachments are the boundary, 37# add`): krz/gitbay, krz/hutch, krz/keycask, krz/orgo, krz/skunky-art
38# so ExecStart names no -repos. cmc/ci-smoke is the nightly isolation 38# and cmc/cleberg.net. The attachments are the boundary, so ExecStart
39# canary; keep it attached or its scheduled build waits forever. 39# names no -repos. To validate a runner change, create a scratch
40# repository, attach this key to it, and run with -repos naming only
41# that repository until the change is proven (Admin wiki, CI runner).
40# 42#
41# Two layers of resource caps. MemoryMax and CPUQuota bound the unit — 43# Two layers of resource caps. MemoryMax and CPUQuota bound the unit —
42# the runner and every build together — which is what keeps the forge 44# the runner and every build together — which is what keeps the forge
e2e/webhook_test.go +2 −2
@@ -112,8 +112,8 @@ func TestWebhooks(t *testing.T) {
112 112
113 recv := startHookReceiver(t) 113 recv := startHookReceiver(t)
114 hookURL := "http://" + recv.addr + "/hook" 114 hookURL := "http://" + recv.addr + "/hook"
115 if _, errOut, code := inst.ssh(t, aliceKey, "", 115 if _, errOut, code := inst.ssh(t, aliceKey, "s3cret\n",
116 "webhook", "add", "alice/proj", hookURL, "--secret", "s3cret"); code != 0 { 116 "webhook", "add", "alice/proj", hookURL, "--secret", "-"); code != 0 {
117 t.Fatalf("webhook add: %s", errOut) 117 t.Fatalf("webhook add: %s", errOut)
118 } 118 }
119 119
internal/control/webhook.go +26 −6
@@ -5,6 +5,7 @@ import (
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "strconv" 7 "strconv"
8 "strings"
8 9
9 "gitbay.org/gitbay/internal/policy" 10 "gitbay.org/gitbay/internal/policy"
10 "gitbay.org/gitbay/internal/protocol" 11 "gitbay.org/gitbay/internal/protocol"
@@ -15,13 +16,17 @@ import (
15func init() { 16func init() {
16 register(Command{Path: []string{"webhook", "add"}, 17 register(Command{Path: []string{"webhook", "add"},
17 Summary: "add a webhook", 18 Summary: "add a webhook",
18 Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]", 19 Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]",
19 Flags: []Flag{ 20 Flags: []Flag{
20 {"--secret", "<s>", "signs deliveries so the receiver can verify them", ""}, 21 {"--secret", "-", "read the secret that signs deliveries from stdin", ""},
21 {"--events", "push,issue.created|*", "which events to send", "*"}, 22 {"--events", "push,issue.created|*", "which events to send", "*"},
22 }, 23 },
23 Examples: []string{"webhook add krz/gitbay https://ci.example.org/hook --events push"}, 24 Examples: []string{
24 Run: runWebhookAdd}) 25 "webhook add krz/gitbay https://ci.example.org/hook --events push",
26 "webhook add krz/gitbay https://ci.example.org/hook --secret - < secret.txt",
27 },
28 ReadsStdin: true,
29 Run: runWebhookAdd})
25 register(Command{Path: []string{"webhook", "list"}, 30 register(Command{Path: []string{"webhook", "list"},
26 Summary: "list webhooks", 31 Summary: "list webhooks",
27 Usage: "webhook list <owner/name>", 32 Usage: "webhook list <owner/name>",
@@ -45,17 +50,21 @@ func init() {
45} 50}
46 51
47func runWebhookAdd(c *Ctx, args []string) int { 52func runWebhookAdd(c *Ctx, args []string) int {
48 f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"}) 53 f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]"})
49 if err != nil { 54 if err != nil {
50 return c.fail(protocol.ExitUsage, "%v", err) 55 return c.fail(protocol.ExitUsage, "%v", err)
51 } 56 }
52 path, url, secret, events := f.pos(0), f.pos(1), f.Value("--secret"), "*" 57 path, url, events := f.pos(0), f.pos(1), "*"
53 if f.Has("--events") { 58 if f.Has("--events") {
54 events = f.Value("--events") 59 events = f.Value("--events")
55 } 60 }
56 if path == "" || url == "" { 61 if path == "" || url == "" {
57 return c.usage() 62 return c.usage()
58 } 63 }
64 // Secrets travel on stdin: argv shows in /proc and in shell history.
65 if f.Has("--secret") && f.Value("--secret") != "-" {
66 return c.fail(protocol.ExitUsage, "the secret is read from stdin, never argv: pipe it and pass --secret - (printf %%s SECRET | ... --secret -)")
67 }
59 repo, code := resolveRepo(c, path, policy.CanAdmin) 68 repo, code := resolveRepo(c, path, policy.CanAdmin)
60 if code >= 0 { 69 if code >= 0 {
61 return code 70 return code
@@ -71,6 +80,17 @@ func runWebhookAdd(c *Ctx, args []string) int {
71 // Exit 1 carries the reason to every client verbatim (#187). 80 // Exit 1 carries the reason to every client verbatim (#187).
72 return c.fail(protocol.ExitFailure, "%v", err) 81 return c.fail(protocol.ExitFailure, "%v", err)
73 } 82 }
83 secret := ""
84 if f.Has("--secret") {
85 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
86 if err != nil {
87 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
88 }
89 secret = strings.TrimRight(string(raw), "\n")
90 if secret == "" {
91 return c.fail(protocol.ExitUsage, "no secret on stdin (pipe it: printf %%s SECRET | ... --secret -)")
92 }
93 }
74 id, err := c.Store.AddWebhook(repo.ID, url, secret, events) 94 id, err := c.Store.AddWebhook(repo.ID, url, secret, events)
75 if err != nil { 95 if err != nil {
76 return c.fail(protocol.ExitFailure, "%v", err) 96 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/webhook_test.go +39
@@ -39,3 +39,42 @@ func TestWebhookAddRefusedURLIsAFailure(t *testing.T) {
39 t.Errorf("missing url: exit %d, want %d", code, protocol.ExitUsage) 39 t.Errorf("missing url: exit %d, want %d", code, protocol.ExitUsage)
40 } 40 }
41} 41}
42
43// The signing secret arrives on stdin with --secret -, like a build
44// secret: a value on the command line is refused before anything is
45// stored, since argv shows in /proc and in shell history (#284).
46func TestWebhookAddSecretFromStdin(t *testing.T) {
47 st, repo, uid := newQueueTestRepo(t)
48 run := func(stdin string, argv ...string) (string, int) {
49 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
50 c.Cfg.Limits.WriteRate = -1
51 c.Cfg.Webhooks.AllowLocal = true
52 c.Stdin = strings.NewReader(stdin)
53 code := Dispatch(c, argv)
54 return errOut.String(), code
55 }
56 msg, code := run("", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "s3cret")
57 if code != protocol.ExitUsage || !strings.Contains(msg, "--secret -") {
58 t.Fatalf("literal secret: exit %d, %q", code, msg)
59 }
60 if msg, code := run("", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "-"); code != protocol.ExitUsage || !strings.Contains(msg, "no secret on stdin") {
61 t.Fatalf("empty stdin: exit %d, %q", code, msg)
62 }
63 if hooks, err := st.ListWebhooks(repo.ID); err != nil || len(hooks) != 0 {
64 t.Fatalf("a refused add stored %+v (%v)", hooks, err)
65 }
66 if msg, code := run("s3cret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/hook", "--secret", "-"); code != protocol.ExitOK {
67 t.Fatalf("piped secret: exit %d, %q", code, msg)
68 }
69 // Without --secret nothing reads stdin and the hook is unsigned.
70 if msg, code := run("not a secret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/other"); code != protocol.ExitOK {
71 t.Fatalf("no secret: exit %d, %q", code, msg)
72 }
73 hooks, err := st.ListWebhooks(repo.ID)
74 if err != nil || len(hooks) != 2 {
75 t.Fatalf("hooks: %+v %v", hooks, err)
76 }
77 if hooks[0].Secret != "s3cret" || hooks[1].Secret != "" {
78 t.Fatalf("secrets: %q, %q", hooks[0].Secret, hooks[1].Secret)
79 }
80}