runner: builds off the runner's address; host egress limited to public ports !484

merged merged by cmc on 2026-09-28 22:33 UTC · krz/gitbay:runner-source-address into main

20 files changed, +502 −46

Layout: unified · split

.gitbay/wiki/Admin.org +11
@@ -716,6 +716,17 @@ The script installs podman, delegates a subuid/subgid range to
716716assuming, enables lingering, and verifies rootless podman actually runs
717717as that user. It is idempotent.
718718
719It also installs nftables. =make deploy-runner= ships
720=deploy/gitbay-runner-egress.nft= to =/etc/gitbay-runner/egress.nft=
721with =gitbay-runner-egress.service=, which loads it and which the
722runner's unit requires; it checks the file with =nft -c=, reloads the
723unit, and runs =deploy/runner-egress-check.sh= as =ci-runner= before
724restarting the runner: =127.0.0.1:22= and the public 22 must answer,
7252222 must not. The table limits the runner's user to =127.0.0.1:22=,
726DNS on loopback, and 22, 80 and 443 on the host's public address; the
727Threat-Model page says why. A restart of =nftables.service= flushes it;
728=systemctl reload gitbay-runner-egress= restores it.
729
719730The drop-in sets =NoNewPrivileges=no=, without which rootless podman
720731cannot call =newuidmap= and the runner refuses to start. That is a
721732considered trade, explained in the file and in the Threat-Model; if you
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +1 −1
@@ -24,7 +24,7 @@
2424| TB4 | Z1 → Z3 git | argv, repository path, stdin packs | argv built by code, never a shell; repository path from the database, not the request (=internal/gitutil=) |
2525| TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, push token, commit objects | the socket is mode 0600 and, on Linux, refuses a peer whose uid is not the daemon's; a request must carry the token sshd minted for its receive-pack (stored hashed in =push_tokens=) and name the same repository, account and scope. The daemon then decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=) |
2626| TB6 | Z4 ↔ Z1 runner channel | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go=) |
27| TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; a trusted build's home is its repository's, an untrusted build's is discarded with it; the network is open (#260) |
27| TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; a trusted build's home is its repository's, an untrusted build's is discarded with it; outbound is open; on the host only the forge's public ports (#260) |
2828| TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) |
2929| TB9 | user content → browser | Markdown and Org bodies, READMEs, filenames | HTML sanitised (=ugcHTML=, =internal/httpd/web.go=, bluemonday); CSP =script-src 'none'= |
3030| TB10| Z6 → everything | host shell | operator SSH on 2222, keys only, fail2ban; append-only offsite backup credentials |
.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +1 −1
@@ -68,7 +68,7 @@ Who may do what:
6868| Build home | trusted: =<workdir>/trusted-home/<owner>/<name>=, one per repository, persistent; untrusted: =<workdir>/build-<id>-home=, removed with the build (=main.go=) |
6969| Secrets | env file 0600 outside the workspace, or =--env NAME= for multi-line values |
7070| Resources | per-build cgroup with =memory.max= and =cpu.max= written by the runner; unit-level =MemoryMax=6G=, =CPUQuota=300%= |
71| Network | podman default (pasta); outbound unrestricted (#260) |
71| Network | pasta; outbound open; a loopback runner's builds run with =--no-map-gw= (=main.go=); on the host only public 22/80/443 (=gitbay-runner-egress.nft=, #260) |
7272| Shutdown | SIGTERM stops claiming and drains in-flight builds; the unit uses =KillMode=mixed= |
7373
7474* Integrations
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -88,7 +88,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
8888| No secrets for untrusted builds | in place | =internal/control/build.go= |
8989| Runner limited to attached repositories | in place | =runnerMayBuild= (=build.go=) |
9090| Build images fixed by the operator | in place | =--pull=never= |
91| Build network egress restricted | gap | #260 |
91| Build network egress restricted | partial | host: loopback closed, public 22/80/443 only (=gitbay-runner-egress.nft=); internet outbound open by decision (#260) |
9292| Build results reused only across equal trust | in place | =SuccessBuildForTree=, =SuccessBuildFor= (=internal/store/builds.go=) |
9393
9494** Availability and operations
.gitbay/wiki/CI.org +10
@@ -64,6 +64,16 @@ seconds: a follower who loses it is told the repository is not found.
6464A restart ends every open follow with a message saying so, rather
6565than holding the drain; follow again once the daemon is back.
6666
67* What a build can reach
68
69Builds have outbound internet access, trusted and untrusted alike. On
70the runner's host an nftables table limits them to the forge's public
71ports 22, 80 and 443, which closes the operator's sshd. The forge is
72reached at the address in =GITBAY_SSH=: on a runner that polls the
73daemon over loopback, =169.254.1.2=, which pasta translates to the
74host's public address. See the Threat-Model page, "What a build can
75reach", for how and why (krz/gitbay#260).
76
6777* The table
6878
6979One =ci.yml= with five jobs, each isolating one rule:
.gitbay/wiki/Threat-Model.org +25
@@ -193,6 +193,31 @@ runner, polling over SSH, clones the commit and runs its steps.
193193 already has rather than naming anything on the internet. On an
194194 instance with open registration that is the difference between a
195195 curated set and arbitrary code from a registry nobody vetted.
196- *What a build can reach.* Outbound internet, trusted or not: a fork's
197 merge request to a Go repository has to fetch its modules. On the
198 runner's host, the rules below limit it to the forge's public ports
199 22, 80 and 443. Under pasta a build's container holds the host's own
200 public address, and pasta translates =169.254.1.2= (its
201 =--map-guest-addr=) to that address. A runner that polls the daemon
202 over loopback starts its containers with =--network
203 pasta:--no-map-gw=, which is podman's default stated explicitly, and
204 gives them =GITBAY_SSH= at =169.254.1.2=, with the forge's port when
205 it is not 22. The runner's source address is =127.0.0.1=. An nftables
206 table (=deploy/gitbay-runner-egress.nft=) rejects every connection
207 the runner's user makes to the host's own addresses except
208 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
209 address: the operator's sshd on 2222 and anything bound to loopback
210 are closed to it. Under rootless podman a build's connections are
211 made by pasta as the runner's user, so the table cannot tell a build
212 from its runner and leaves =127.0.0.1:22= open; that no build reaches
213 the host's loopback is measured from inside a build by runbook R3.
214 The runner does not start without the table. The SSH auth limiter
215 counts failures per source address and, once an address is over the
216 limit, refuses every key from it until the window passes, the
217 runner's included; with registration open or by invite an unknown
218 key never counts (krz/gitbay#260). Under =-isolation none= a build
219 runs on the host and shares its loopback; the table still applies,
220 since it runs as the same user.
196221
197222Under =-isolation none=, anything a step can do as the runner's user a
198223pushed =ci.yml= can do. Under podman a step is confined to its
.gitbay/wiki/Users.org +6 −3
@@ -563,9 +563,12 @@ with =sh -c= on the instance's runner, stopping at the first failure;
563563a broken config surfaces as a failed =ci/config= status. Environment:
564564=GITBAY_REPO=,
565565=GITBAY_SHA=, =GITBAY_REF=, =GITBAY_JOB=, =CI=true=, and =GITBAY_SSH=,
566the instance's ssh destination as the build reaches it (=git@gitbay.org=
567from a runner elsewhere; inside a container on the server's own runner
568the host is at a private address the runner fills in). A job that
566the instance's ssh destination as the build reaches it: on the forge's
567own runner, =git@169.254.1.2=, pasta's address for the host, with
568=:port= when the instance's ssh is not on 22; from any other runner,
569the destination that runner polls (its =-remote=, such as
570=git@gitbay.org=). Use it as =ssh://$GITBAY_SSH/owner/name.git= or
571=ssh ssh://$GITBAY_SSH …=, which work in either form. A job that
569572talks back to the instance — a release asset, a comment, a push to a
570573pages branch — uses =$GITBAY_SSH= with a key it holds as a secret;
571574the build's container has no key of its own. Two things about that
CHANGELOG.org +9
@@ -133,6 +133,15 @@ for the eighteen commands whose CLI path differs from the registry's
133133 same image. =repo settings require-contexts= names status contexts
134134 that must report green; setting any turns require-checks on, and one
135135 not yet reported counts as pending. (#258)
136- A runner polling over loopback gives its podman builds =GITBAY_SSH=
137 at =169.254.1.2=, pasta's address for the host, with the port when
138 it is not 22, since under pasta the container holds the host's
139 public address. An nftables table limits what the runner's user
140 reaches on the host to =127.0.0.1:22=, DNS on loopback, and public
141 22, 80 and 443. The runner unit now requires the egress unit: run
142 =deploy/runner-podman-setup.sh= (it installs nftables) before =make
143 deploy-runner=. Deploy gitbayd, then the runner, after validating on
144 a scratch repository per the CI page. (#260)
136145
137146* v1.36.0 — 2026-09-23
138147
Makefile +10 −2
@@ -71,14 +71,22 @@ deploy-runner: preflight
7171 $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(RUNNER_BIN) ./cmd/gitbay-runner
7272 @echo "==> pushing runner to $(HOST)"
7373 ./deploy/copy.sh $(HOST) $(PORT) $(RUNNER_BIN) /usr/local/bin/gitbay-runner.new
74 ssh -p $(PORT) root@$(HOST) 'mkdir -p /etc/systemd/system/gitbay-runner.service.d'
74 ssh -p $(PORT) root@$(HOST) 'mkdir -p /etc/systemd/system/gitbay-runner.service.d /etc/gitbay-runner'
7575 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner.override.conf /etc/systemd/system/gitbay-runner.service.d/override.conf
7676 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.service /etc/systemd/system/gitbay-runner-prune.service
7777 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.timer /etc/systemd/system/gitbay-runner-prune.timer
78 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.nft /etc/gitbay-runner/egress.nft
79 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.service /etc/systemd/system/gitbay-runner-egress.service
80 @echo "==> loading the egress rule"
81 ssh -p $(PORT) root@$(HOST) 'set -eu; \
82 nft -c -f /etc/gitbay-runner/egress.nft; \
83 systemctl daemon-reload; \
84 systemctl enable gitbay-runner-egress.service; \
85 systemctl reload-or-restart gitbay-runner-egress.service'
86 ssh -p $(PORT) root@$(HOST) 'sh -s' < deploy/runner-egress-check.sh
7887 ssh -p $(PORT) root@$(HOST) 'set -eu; \
7988 chmod 755 /usr/local/bin/gitbay-runner.new; \
8089 mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \
81 systemctl daemon-reload; \
8290 systemctl enable --now gitbay-runner-prune.timer; \
8391 systemctl restart gitbay-runner; \
8492 systemctl --no-pager --lines=3 status gitbay-runner; \
cmd/gitbay-runner/env_test.go +44 −13
@@ -190,26 +190,57 @@ func TestSplitEnvKeepsMultilineOutOfTheFile(t *testing.T) {
190190 }
191191}
192192
193// A build that talks back to the instance — releases, comments — needs an
194// address that works from where it runs. GITBAY_SSH carries the runner's
195// remote; under podman a loopback remote is rewritten to the address at
196// which pasta exposes the host, since the host's own addresses belong to
197// the container inside it.
193// A build that talks back to the instance needs an address that works
194// from where it runs. Under pasta a podman build holds the host's public
195// address, so a runner polling over loopback gives its podman builds
196// 169.254.1.2, pasta's address for the host, with the claim's port when
197// it is not 22, and never the loopback address it polls. Any other runner
198// passes on its own remote (#260).
198199func TestStepEnvCarriesInstanceAddress(t *testing.T) {
199200 env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org")
200201 if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") {
201202 t.Errorf("GITBAY_SSH missing: %q", env)
202203 }
203 for _, tc := range []struct{ remote, isolation, want string }{
204 {"git@127.0.0.1", isolationNone, "git@127.0.0.1"},
205 {"git@127.0.0.1", isolationPodman, "git@169.254.1.2"},
206 {"git@localhost", isolationPodman, "git@169.254.1.2"},
207 {"git@gitbay.org", isolationPodman, "git@gitbay.org"},
208 {"gitbay.org", isolationPodman, "gitbay.org"},
204 for _, tc := range []struct{ remote, isolation, public, want string }{
205 {"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@169.254.1.2"},
206 {"git@127.0.0.1", isolationPodman, "git@gitbay.test:22", "git@169.254.1.2"},
207 {"git@127.0.0.1", isolationPodman, "git@gitbay.test:2022", "git@169.254.1.2:2022"},
208 {"git@127.0.0.1", isolationPodman, "git@[2001:db8::1]:2022", "git@169.254.1.2:2022"},
209 {"git@127.0.0.1", isolationPodman, "git@2001:db8::1", "git@169.254.1.2"},
210 {"git@localhost", isolationPodman, "git@gitbay.org", "git@169.254.1.2"},
211 {"forge@::1", isolationPodman, "git@gitbay.org", "forge@169.254.1.2"},
212 {"127.0.0.1", isolationPodman, "git@gitbay.org", "git@169.254.1.2"},
213 {"git@127.0.0.1", isolationPodman, "", "git@169.254.1.2"},
214 {"git@127.0.0.1", isolationNone, "git@gitbay.org", "git@127.0.0.1"},
215 {"git@127.0.0.1", isolationNone, "", "git@127.0.0.1"},
216 {"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"},
217 {"git@gitbay.org", isolationPodman, "", "git@gitbay.org"},
218 {"gitbay.org", isolationPodman, "", "gitbay.org"},
219 {"ci@forge.internal", isolationNone, "git@gitbay.org", "ci@forge.internal"},
209220 } {
210221 r := &runner{remote: tc.remote, isolation: tc.isolation}
211 if got := r.buildSSH(); got != tc.want {
212 t.Errorf("remote %s under %s: got %s want %s", tc.remote, tc.isolation, got, tc.want)
222 if got := r.buildSSH(tc.public); got != tc.want {
223 t.Errorf("remote %s under %s, public %q: got %s want %s", tc.remote, tc.isolation, tc.public, got, tc.want)
224 }
225 }
226}
227
228// Only a runner that polls over loopback shares an address a build could
229// connect from, so only its builds state --no-map-gw rather than rely on
230// podman's default (#260).
231func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) {
232 for _, tc := range []struct {
233 remote string
234 want []string
235 }{
236 {"git@127.0.0.1", []string{"--network", "pasta:--no-map-gw"}},
237 {"localhost", []string{"--network", "pasta:--no-map-gw"}},
238 {"git@::1", []string{"--network", "pasta:--no-map-gw"}},
239 {"git@gitbay.org", nil},
240 } {
241 r := &runner{remote: tc.remote, isolation: isolationPodman}
242 if got := r.buildNetwork(); strings.Join(got, " ") != strings.Join(tc.want, " ") {
243 t.Errorf("remote %s: %q, want %q", tc.remote, got, tc.want)
213244 }
214245 }
215246}
cmd/gitbay-runner/isolate.go +1
@@ -151,6 +151,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
151151 args = append(args,
152152 "--name", name,
153153 "--env-file", envFile)
154 args = append(args, r.buildNetwork()...)
154155 args = append(args, inheritArgs(inherit)...)
155156 args = append(args,
156157 "--volume", dir+":/workspace:rw",
cmd/gitbay-runner/main.go +61 −23
@@ -16,6 +16,7 @@ import (
1616 "io"
1717 "io/fs"
1818 "log"
19 "net"
1920 "os"
2021 "os/exec"
2122 "os/signal"
@@ -42,7 +43,10 @@ type job struct {
4243 // Trusted is false for a merge request head from a fork, and when the
4344 // server did not say: such a build gets no secrets and a home of its
4445 // own (#255).
45 Trusted bool `json:"trusted"`
46 Trusted bool `json:"trusted"`
47 // SSH is the instance's public ssh destination; a runner polling
48 // over loopback takes its port for its builds (#260).
49 SSH string `json:"ssh"`
4650 Secrets map[string]string `json:"secrets"`
4751}
4852
@@ -425,7 +429,7 @@ func (r *runner) run(j job) bool {
425429 }
426430 }
427431
428 env := stepEnv(j, home, r.buildSSH())
432 env := stepEnv(j, home, r.buildSSH(j.SSH))
429433 return r.runSteps(j, dir, env, sink, deadline, runStep)
430434}
431435
@@ -483,27 +487,61 @@ func removeTree(dir string) error {
483487 return os.RemoveAll(dir)
484488}
485489
486// buildSSH is the instance's ssh destination as a build reaches it. Under
487// podman, pasta gives the container the host's own addresses, so a
488// loopback remote — the runner on the server itself — is unreachable by
489// that name; pasta exposes the host at 169.254.1.2, its
490// --map-host-loopback default. Any other remote is a real host elsewhere
491// and works as it is.
492func (r *runner) buildSSH() string {
493 if r.isolation != isolationPodman {
494 return r.remote
495 }
496 user, host, hasUser := strings.Cut(r.remote, "@")
497 if !hasUser {
498 user, host = "", user
499 }
500 if host != "127.0.0.1" && host != "localhost" && host != "::1" {
501 return r.remote
502 }
503 if hasUser {
504 return user + "@169.254.1.2"
505 }
506 return "169.254.1.2"
490// loopbackRemote reports whether the runner polls the daemon on its own
491// host over loopback.
492func (r *runner) loopbackRemote() bool {
493 _, host, ok := strings.Cut(r.remote, "@")
494 if !ok {
495 host = r.remote
496 }
497 return host == "127.0.0.1" || host == "localhost" || host == "::1"
498}
499
500// hostAddr is the address a podman build under pasta reaches the host
501// at: pasta's --map-guest-addr, which podman sets to this address and
502// which pasta translates to the host's public address.
503const hostAddr = "169.254.1.2"
504
505// buildSSH is the instance's ssh destination as a build reaches it.
506// Under pasta a podman build holds the host's own public address, so the
507// instance's public name resolves to the container itself. A runner
508// polling over loopback runs on the daemon's host, and its podman builds
509// get hostAddr with the user from -remote and the port from the claim's
510// destination when it is not 22. Any other runner's -remote is the path
511// that reaches the forge from where it runs, so its builds get that.
512func (r *runner) buildSSH(public string) string {
513 if r.isolation == isolationPodman && r.loopbackRemote() {
514 user, _, ok := strings.Cut(r.remote, "@")
515 if !ok || user == "" {
516 user = "git"
517 }
518 dest := hostAddr
519 _, hostport, ok := strings.Cut(public, "@")
520 if !ok {
521 hostport = public
522 }
523 if _, port, err := net.SplitHostPort(hostport); err == nil && port != "" && port != "22" {
524 dest = net.JoinHostPort(hostAddr, port)
525 }
526 return user + "@" + dest
527 }
528 return r.remote
529}
530
531// buildNetwork is the podman network option for a build on the daemon's
532// host. A build reaches the host at hostAddr, which pasta translates to
533// the host's public address, and cannot reach the host's loopback, so
534// none of its connections arrive from 127.0.0.1, the address the runner
535// polls from; the SSH auth limiter counts failures per source address
536// (#260). podman passes --no-map-gw to pasta by default; it is stated
537// here so the build's view of the host does not depend on that default.
538// The host's nftables table (deploy/gitbay-runner-egress.nft) limits
539// what a build reaches on the host to 22, 80 and 443.
540func (r *runner) buildNetwork() []string {
541 if !r.loopbackRemote() {
542 return nil
543 }
544 return []string{"--network", "pasta:--no-map-gw"}
507545}
508546
509547// stepEnv builds the environment a build step runs with. It is
deploy/gitbay-runner-egress.nft added +66
@@ -0,0 +1,66 @@
1#!/usr/sbin/nft -f
2# Host egress for CI builds (#260). Loaded by gitbay-runner-egress.service,
3# which gitbay-runner.service requires, so the runner does not start
4# without it. `make deploy-runner` installs it as
5# /etc/gitbay-runner/egress.nft.
6#
7# Under rootless podman with pasta, a build's connections are made by
8# pasta on the host, from sockets owned by the runner's user, ci-runner.
9# nftables sees them exactly as it sees the runner's own ssh, so this
10# table cannot tell a build from its runner. It limits what that user
11# reaches on this host, and the runner needs little: 127.0.0.1:22, to
12# poll, clone and stream logs.
13#
14# Every packet to one of the host's own addresses, loopback or public,
15# leaves through lo, so the output hook sees host-bound traffic as
16# oifname "lo". Traffic to other hosts is not matched: builds keep
17# outbound internet access, trusted or not (go mod download needs it).
18#
19# What ci-runner may reach on this host:
20# 127.0.0.1:22 the forge over loopback, for the runner. This
21# table cannot close it to builds. A build reaches
22# the host at 169.254.1.2, pasta's --map-guest-addr,
23# which pasta translates to the host's public
24# address; --no-map-gw (podman's default, which the
25# runner also states) adds no mapping to loopback.
26# Runbook R3 checks from inside a build whether
27# 127.0.0.1:22 answers.
28# loopback :53 the host's resolver, for when the host's nameserver
29# is a loopback address. That pasta forwards a
30# build's DNS there is to be confirmed from inside a
31# build by runbook R3, not assumed.
32# public 22/80/443 the forge, as anyone on the internet reaches it,
33# and as a build reaches it through 169.254.1.2.
34# Everything else is rejected: the admin sshd on 2222 on every address,
35# and any service bound to loopback. -isolation none builds run as the
36# same user and get the same rule.
37#
38# The account name is resolved when the file is loaded. A restart of
39# nftables.service (flush ruleset) removes this table; `systemctl
40# reload gitbay-runner-egress` puts it back.
41#
42# The first line creates the table if it is missing, so the delete never
43# fails; the file then replaces it in one transaction, and a reload never
44# leaves a moment without the rule. The uid match sits in the base
45# chain's one rule rather than in a `!=` accept, because a packet with no
46# socket (a reset the kernel sends) matches neither `==` nor `!=` on
47# skuid and would otherwise fall through to the reject.
48
49table inet gitbay_runner
50delete table inet gitbay_runner
51
52table inet gitbay_runner {
53 chain output {
54 type filter hook output priority filter; policy accept;
55 oifname "lo" meta skuid "ci-runner" jump host
56 }
57
58 chain host {
59 ip daddr 127.0.0.1 tcp dport 22 accept
60 ip daddr 127.0.0.0/8 meta l4proto { tcp, udp } th dport 53 accept
61 ip6 daddr ::1 meta l4proto { tcp, udp } th dport 53 accept
62 ip daddr != 127.0.0.0/8 tcp dport { 22, 80, 443 } accept
63 ip6 daddr != ::1 tcp dport { 22, 80, 443 } accept
64 counter reject
65 }
66}
deploy/gitbay-runner-egress.service added +29
@@ -0,0 +1,29 @@
1# Loads the CI runner's host egress rule (#260,
2# deploy/gitbay-runner-egress.nft). gitbay-runner.service requires this
3# unit, so the runner starts only with the rule in force; stopping this
4# unit removes the table and stops the runner with it.
5#
6# Ordered after nftables.service and ufw.service: either may rewrite the
7# ruleset at boot, and nftables.service's default config starts with
8# flush ruleset. A missing unit in After= is ignored.
9#
10# Reload re-reads the file and replaces the table in one transaction; it
11# does not restart the runner, which a restart of this unit would
12# (Requires= propagates restarts). `make deploy-runner` reloads.
13#
14# Stop uses destroy, which succeeds when the table is already gone (a
15# flush ruleset removes it); delete would fail and leave the unit failed.
16[Unit]
17Description=Host egress rule for CI builds
18After=nftables.service ufw.service
19Before=gitbay-runner.service
20
21[Service]
22Type=oneshot
23RemainAfterExit=yes
24ExecStart=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
25ExecReload=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
26ExecStop=/usr/sbin/nft destroy table inet gitbay_runner
27
28[Install]
29WantedBy=multi-user.target
deploy/gitbay-runner.override.conf +9
@@ -22,6 +22,15 @@
2222# container store lives under the runner's home, which ProtectSystem
2323# would otherwise make read-only. Prepare the host with
2424# deploy/runner-podman-setup.sh before deploying a runner that isolates.
25[Unit]
26# The host egress rule (#260, gitbay-runner-egress.nft) limits what this
27# unit's user reaches on the host: 127.0.0.1:22 for the runner, the
28# forge's public 22, 80 and 443 for builds, nothing else. Required, so
29# the runner does not start without it: a table that failed to load must
30# not mean builds reach the admin sshd.
31Requires=gitbay-runner-egress.service
32After=gitbay-runner-egress.service
33
2534[Service]
2635# The runner polls as a non-admin account with a runner-scoped key, and
2736# claims only the repositories that key is attached to (`repo runner
deploy/runner-egress-check.sh added +41
@@ -0,0 +1,41 @@
1#!/bin/sh
2# Check the CI runner's host egress rule (#260) as the runner's user:
3# the forge over loopback on 22 must answer (the runner polls there),
4# and the admin sshd on 2222 must not, on loopback or the public
5# address. `make deploy-runner` runs this after loading the rule and
6# before restarting the runner, and stops on a failure.
7#
8# ssh -p 2222 root@bay1 'sh -s' < deploy/runner-egress-check.sh
9set -eu
10
11RUNNER_USER="${RUNNER_USER:-ci-runner}"
12# hostname -I lists the host's addresses, IPv4 first on bay1; the first
13# is the public one there.
14public=$(hostname -I | awk '{print $1}')
15
16probe() {
17 su -s /bin/bash "$RUNNER_USER" -c "timeout 5 bash -c 'exec 3<>/dev/tcp/$1/$2'" </dev/null 2>/dev/null
18}
19
20nft list table inet gitbay_runner >/dev/null
21
22# The runner polls 127.0.0.1:22. If the rule blocks that, the running
23# runner is already cut off, so remove the table: CI keeps polling as it
24# did before the deploy, and the exit still stops make before the restart.
25if ! probe 127.0.0.1 22; then
26 nft destroy table inet gitbay_runner
27 echo "$RUNNER_USER cannot reach 127.0.0.1:22 with the egress rule loaded;" >&2
28 echo "removed table inet gitbay_runner so the runner keeps polling. Fix the rule and deploy again." >&2
29 exit 1
30fi
31if ! probe "$public" 22; then
32 echo "$RUNNER_USER cannot reach $public:22: builds would not reach the forge" >&2
33 exit 1
34fi
35for dest in 127.0.0.1:2222 "$public:2222"; do
36 if probe "${dest%:*}" "${dest##*:}"; then
37 echo "$RUNNER_USER reaches $dest: the egress rule is not in force" >&2
38 exit 1
39 fi
40done
41echo "egress for $RUNNER_USER: 127.0.0.1:22 and $public:22 open, 2222 refused"
deploy/runner-podman-setup.sh +10
@@ -28,6 +28,16 @@ if ! command -v podman >/dev/null 2>&1; then
2828fi
2929podman --version
3030
31# nft loads the runner's host egress rule (#260,
32# deploy/gitbay-runner-egress.nft), which `make deploy-runner` ships and
33# the runner's unit requires. Without nft the runner does not start.
34echo "==> installing nftables"
35if ! command -v nft >/dev/null 2>&1; then
36 apt-get update
37 DEBIAN_FRONTEND=noninteractive apt-get install -y nftables
38fi
39nft --version
40
3141# Rootless podman maps container uids into a range delegated to the user.
3242# Without these the runner's `podman run` fails with a mapping error.
3343echo "==> subuid/subgid for $RUNNER_USER"
internal/control/build.go +23 −2
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "io"
88 "log/slog"
9 "net"
910 "regexp"
1011 "slices"
1112 "strconv"
@@ -460,6 +461,23 @@ func runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error) {
460461// walking it forever.
461462const maxOrphanSkip = 50
462463
464// publicSSH is the instance's ssh destination as anyone outside reaches
465// it. A runner on the daemon's own host polls over loopback and takes
466// the port from it for its builds' GITBAY_SSH, which names pasta's
467// address for the host (#260). The port is added only when it is not
468// 22: hutch and orgo build ssh://$GITBAY_SSH/... URLs, valid in both
469// forms. Empty when site_url is not set.
470func publicSSH(c *Ctx) string {
471 host := c.Cfg.SiteHost()
472 if host == "" {
473 return ""
474 }
475 if p := c.Cfg.SSH.Port; p != 0 && p != 22 {
476 return "git@" + net.JoinHostPort(host, strconv.Itoa(p))
477 }
478 return "git@" + host
479}
480
463481func runRunnerNext(c *Ctx, args []string) int {
464482 key, code := runnerSession(c)
465483 if code >= 0 {
@@ -562,10 +580,13 @@ func runRunnerNext(c *Ctx, args []string) int {
562580 Image string `json:"image,omitempty"`
563581 // Trusted is always sent: a runner decides a build's home and
564582 // secrets from it, and reads a missing field as untrusted (#255).
565 Trusted bool `json:"trusted"`
583 Trusted bool `json:"trusted"`
584 // SSH is the instance's public destination; a runner polling
585 // over loopback takes its port for the build's GITBAY_SSH (#260).
586 SSH string `json:"ssh,omitempty"`
566587 Secrets map[string]string `json:"secrets,omitempty"`
567588 }{ID: b.ID, Repo: repo.Path(), Number: b.Number, Job: b.Job, SHA: b.SHA, Ref: b.Ref,
568 Steps: steps, Image: b.Image, Trusted: b.Trusted, Secrets: secrets}
589 Steps: steps, Image: b.Image, Trusted: b.Trusted, SSH: publicSSH(c), Secrets: secrets}
569590 return c.emit(d, func(w io.Writer) {
570591 fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA)
571592 })
internal/control/runnernext_test.go +29
@@ -266,3 +266,32 @@ func TestRunnerNextSaysWhetherTrusted(t *testing.T) {
266266 }
267267 }
268268}
269
270// A build on the daemon's own host is given the public destination, not
271// the loopback address its runner polls. The port rides along only when
272// it is not 22, so ssh://$GITBAY_SSH/<owner>/<name>.git is a valid URL
273// either way (#260).
274func TestRunnerNextCarriesPublicSSH(t *testing.T) {
275 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
276 for _, tc := range []struct {
277 port int
278 want string
279 }{
280 {0, `"ssh":"git@x.test"`},
281 {22, `"ssh":"git@x.test"`},
282 {2022, `"ssh":"git@x.test:2022"`},
283 } {
284 if _, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true); err != nil {
285 t.Fatal(err)
286 }
287 c, out := runnerCtx(st, uid, root) // site_url https://x.test
288 c.Cfg.SSH.Port = tc.port
289 c.JSON = true
290 if code := runRunnerNext(c, nil); code != protocol.ExitOK {
291 t.Fatalf("port %d: runner next: exit %d, output:\n%s", tc.port, code, out.String())
292 }
293 if !strings.Contains(out.String(), tc.want) {
294 t.Fatalf("port %d: claim lacks %s:\n%s", tc.port, tc.want, out.String())
295 }
296 }
297}
internal/sshd/sshd_test.go +115
@@ -296,3 +296,118 @@ func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) {
296296 }
297297 }
298298}
299
300// authMeta is the connection metadata authenticate reads: only the
301// remote address.
302type authMeta struct {
303 ssh.ConnMetadata
304 addr net.Addr
305}
306
307func (m authMeta) RemoteAddr() net.Addr { return m.addr }
308
309func authKey(t *testing.T) ssh.PublicKey {
310 t.Helper()
311 pub, _, err := ed25519.GenerateKey(rand.Reader)
312 if err != nil {
313 t.Fatal(err)
314 }
315 k, err := ssh.NewPublicKey(pub)
316 if err != nil {
317 t.Fatal(err)
318 }
319 return k
320}
321
322// authServer is a Server holding what authenticate uses: a store with a
323// runner account's key, the registration mode, and a limiter of three
324// failures a minute.
325func authServer(t *testing.T, mode string) (*Server, ssh.PublicKey) {
326 t.Helper()
327 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
328 if err != nil {
329 t.Fatal(err)
330 }
331 t.Cleanup(func() { st.Close() })
332 if err := st.MigrateUp(); err != nil {
333 t.Fatal(err)
334 }
335 uid, err := st.CreateUser("ci", false)
336 if err != nil {
337 t.Fatal(err)
338 }
339 runner := authKey(t)
340 if err := st.AddSSHKey(uid, ssh.FingerprintSHA256(runner), runner.Type(), runner.Marshal(), "runner", ""); err != nil {
341 t.Fatal(err)
342 }
343 cfg := config.Default()
344 cfg.Registration.Mode = mode
345 return &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(3, time.Minute)}, runner
346}
347
348// With registration closed an unknown key counts against its address.
349// Below the limit a known key's success clears the count. At the limit
350// authenticate refuses before it looks at the key, so the runner's own
351// key from that address is refused too and its success never runs to
352// clear anything, until the window passes. Another address is not
353// affected. This is why a build must not share the runner's source
354// address (#260).
355func TestAuthLockoutHoldsAgainstTheRunnersKey(t *testing.T) {
356 s, runner := authServer(t, "closed")
357 stranger := authKey(t)
358 failTimes := func(n int) {
359 t.Helper()
360 for i := 0; i < n; i++ {
361 if _, err := s.authenticate(fromLoopback, stranger); err == nil {
362 t.Fatal("unknown key admitted with registration closed")
363 }
364 }
365 }
366
367 failTimes(2)
368 if _, err := s.authenticate(fromLoopback, runner); err != nil {
369 t.Fatalf("runner below the limit: %v", err)
370 }
371 failTimes(2)
372 if _, err := s.authenticate(fromLoopback, runner); err != nil {
373 t.Fatalf("runner after its success cleared the count: %v", err)
374 }
375
376 failTimes(3)
377 for i := 0; i < 2; i++ {
378 if _, err := s.authenticate(fromLoopback, runner); err == nil || !strings.Contains(err.Error(), "too many") {
379 t.Fatalf("attempt %d from a locked-out address: %v, want refused", i+1, err)
380 }
381 }
382 if _, err := s.authenticate(fromPublic, runner); err != nil {
383 t.Fatalf("another address was locked out too: %v", err)
384 }
385
386 s.authLimiter.seen["127.0.0.1"].start = time.Now().Add(-2 * time.Minute)
387 if _, err := s.authenticate(fromLoopback, runner); err != nil {
388 t.Fatalf("runner after the window passed: %v", err)
389 }
390}
391
392// With registration open or by invite, an unknown key is admitted to run
393// register and never counts, so no number of unknown-key attempts locks
394// the runner's address out. gitbay.org runs open registration (#260).
395func TestAuthUnknownKeyCountsOnlyWhenClosed(t *testing.T) {
396 for _, mode := range []string{"open", "invite"} {
397 s, runner := authServer(t, mode)
398 for i := 0; i < 10; i++ {
399 p, err := s.authenticate(fromLoopback, authKey(t))
400 if err != nil || p.Extensions["anon-key"] == "" {
401 t.Fatalf("%s: unknown key %d: %v %+v", mode, i+1, err, p)
402 }
403 }
404 if _, err := s.authenticate(fromLoopback, runner); err != nil {
405 t.Fatalf("%s: runner refused after unknown keys: %v", mode, err)
406 }
407 }
408}
409
410var (
411 fromLoopback = authMeta{addr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 40000}}
412 fromPublic = authMeta{addr: &net.TCPAddr{IP: net.IPv4(203, 0, 113, 7), Port: 40000}}
413)