Commit 24c714dba2

24c714dba276040d4998db6f0c20aad2dac7883e

parent: 26e387e932

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:24 UTC

runner: builds reach the forge at pasta's host address

Ref #260

Layout: unified · split

.gitbay/wiki/CI.org +6 −5
@@ -67,11 +67,12 @@ than holding the drain; follow again once the daemon is back.
67* What a build can reach 67* What a build can reach
68 68
69Builds have outbound internet access, trusted and untrusted alike. On 69Builds have outbound internet access, trusted and untrusted alike. On
70the runner's host they reach only the forge's public ports 22, 80 and 70the runner's host an nftables table limits them to the forge's public
71443: not the host's loopback, not the operator's sshd. The forge is 71ports 22, 80 and 443, which closes the operator's sshd. The forge is
72reached at its public address, the one in =GITBAY_SSH=. See the 72reached at the address in =GITBAY_SSH=: on a runner that polls the
73Threat-Model page, "What a build can reach", for how and why 73daemon over loopback, =169.254.1.2=, which pasta translates to the
74(krz/gitbay#260). 74host's public address. See the Threat-Model page, "What a build can
75reach", for how and why (krz/gitbay#260).
75 76
76* The table 77* The table
77 78
.gitbay/wiki/Threat-Model.org +20 −17
@@ -195,26 +195,29 @@ runner, polling over SSH, clones the commit and runs its steps.
195 curated set and arbitrary code from a registry nobody vetted. 195 curated set and arbitrary code from a registry nobody vetted.
196- *What a build can reach.* Outbound internet, trusted or not: a fork's 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 197 merge request to a Go repository has to fetch its modules. On the
198 runner's host, only the forge's public ports 22, 80 and 443, exactly 198 runner's host, the rules below limit it to the forge's public ports
199 as anyone on the internet reaches them. Two layers keep it there. A 199 22, 80 and 443. Under pasta a build's container holds the host's own
200 runner that polls the daemon over loopback starts its containers with 200 public address, and pasta translates =169.254.1.2= (its
201 pasta's gateway mapping off, so a build does not reach the host's 201 =--map-guest-addr=) to that address. A runner that polls the daemon
202 loopback, and =GITBAY_SSH= names the public address; that keeps the 202 over loopback starts its containers with =--network
203 runner's source address, =127.0.0.1=, one no build connects from. And 203 pasta:--no-map-gw=, which is podman's default stated explicitly, and
204 an nftables table (=deploy/gitbay-runner-egress.nft=) rejects every 204 gives them =GITBAY_SSH= at =169.254.1.2=, with the forge's port when
205 connection the runner's user makes to the host's own addresses except 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
206 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public 208 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
207 address: the operator's sshd on 2222 and anything bound to loopback 209 address: the operator's sshd on 2222 and anything bound to loopback
208 are closed to builds. Under rootless podman a build's connections are 210 are closed to it. Under rootless podman a build's connections are
209 made by pasta as the runner's user, so the table cannot tell a build 211 made by pasta as the runner's user, so the table cannot tell a build
210 from its runner and leaves =127.0.0.1:22= open; the gateway mapping 212 from its runner and leaves =127.0.0.1:22= open; that no build reaches
211 is what closes it to builds. The runner does not start without the 213 the host's loopback is measured from inside a build by runbook R3.
212 table. The SSH auth limiter counts failures per source address and, 214 The runner does not start without the table. The SSH auth limiter
213 once an address is over the limit, refuses every key from it until 215 counts failures per source address and, once an address is over the
214 the window passes, the runner's included; with registration open or 216 limit, refuses every key from it until the window passes, the
215 by invite an unknown key never counts (krz/gitbay#260). Under 217 runner's included; with registration open or by invite an unknown
216 =-isolation none= a build runs on the host and shares its loopback; 218 key never counts (krz/gitbay#260). Under =-isolation none= a build
217 the table still applies, since it runs as the same user. 219 runs on the host and shares its loopback; the table still applies,
220 since it runs as the same user.
218 221
219Under =-isolation none=, anything a step can do as the runner's user a 222Under =-isolation none=, anything a step can do as the runner's user a
220pushed =ci.yml= can do. Under podman a step is confined to its 223pushed =ci.yml= can do. Under podman a step is confined to its
.gitbay/wiki/Users.org +6 −5
@@ -563,11 +563,12 @@ with =sh -c= on the instance's runner, stopping at the first failure;
563a broken config surfaces as a failed =ci/config= status. Environment: 563a broken config surfaces as a failed =ci/config= status. Environment:
564=GITBAY_REPO=, 564=GITBAY_REPO=,
565=GITBAY_SHA=, =GITBAY_REF=, =GITBAY_JOB=, =CI=true=, and =GITBAY_SSH=, 565=GITBAY_SHA=, =GITBAY_REF=, =GITBAY_JOB=, =CI=true=, and =GITBAY_SSH=,
566the instance's ssh destination as the build reaches it (=git@gitbay.org=, 566the instance's ssh destination as the build reaches it: on the forge's
567the instance's public address, from a runner elsewhere and from a 567own runner, =git@169.254.1.2=, pasta's address for the host; from a
568container on the server's own runner alike; =git@host:port= on an 568runner elsewhere, the instance's public address (=git@gitbay.org=).
569instance whose ssh is not on 22, so use it as =ssh://$GITBAY_SSH/owner/name.git= 569Either carries =:port= when the instance's ssh is not on 22, so use it
570or =ssh ssh://$GITBAY_SSH …=, which work in both forms). A job that 570as =ssh://$GITBAY_SSH/owner/name.git= or =ssh ssh://$GITBAY_SSH …=,
571which work in both forms. A job that
571talks back to the instance — a release asset, a comment, a push to a 572talks back to the instance — a release asset, a comment, a push to a
572pages branch — uses =$GITBAY_SSH= with a key it holds as a secret; 573pages branch — uses =$GITBAY_SSH= with a key it holds as a secret;
573the build's container has no key of its own. Two things about that 574the build's container has no key of its own. Two things about that
CHANGELOG.org +9 −3
@@ -133,9 +133,15 @@ for the eighteen commands whose CLI path differs from the registry's
133 same image. =repo settings require-contexts= names status contexts 133 same image. =repo settings require-contexts= names status contexts
134 that must report green; setting any turns require-checks on, and one 134 that must report green; setting any turns require-checks on, and one
135 not yet reported counts as pending. (#258) 135 not yet reported counts as pending. (#258)
136- Builds lose host loopback and reach only the forge's public 22, 80 136- A runner polling over loopback gives its podman builds =GITBAY_SSH=
137 and 443 on the host. Deploy gitbayd, then the runner, after 137 at =169.254.1.2=, pasta's address for the host, with the port when
138 validating on a scratch repository per the CI page. (#260) 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)
139 145
140* v1.36.0 — 2026-09-23 146* v1.36.0 — 2026-09-23
141 147
cmd/gitbay-runner/env_test.go +21 −12
@@ -191,23 +191,31 @@ func TestSplitEnvKeepsMultilineOutOfTheFile(t *testing.T) {
191} 191}
192 192
193// A build that talks back to the instance needs an address that works 193// A build that talks back to the instance needs an address that works
194// from where it runs. A runner polling over loopback keeps its podman 194// from where it runs. Under pasta a podman build holds the host's public
195// builds off the host's loopback, so they get the instance's public 195// address, so a runner polling over loopback gives its podman builds
196// destination from the claim, port included when it is not 22; any other 196// 169.254.1.2, pasta's address for the host, with the claim's port when
197// remote is used as it is (#260). 197// it is not 22, and never the loopback address it polls. Any other runner
198// passes on the claim's destination, or its own remote without one (#260).
198func TestStepEnvCarriesInstanceAddress(t *testing.T) { 199func TestStepEnvCarriesInstanceAddress(t *testing.T) {
199 env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org") 200 env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org")
200 if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") { 201 if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") {
201 t.Errorf("GITBAY_SSH missing: %q", env) 202 t.Errorf("GITBAY_SSH missing: %q", env)
202 } 203 }
203 for _, tc := range []struct{ remote, isolation, public, want string }{ 204 for _, tc := range []struct{ remote, isolation, public, want string }{
204 {"git@127.0.0.1", isolationNone, "git@gitbay.org", "git@127.0.0.1"}, 205 {"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@169.254.1.2"},
205 {"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@gitbay.org"}, 206 {"git@127.0.0.1", isolationPodman, "git@gitbay.test:22", "git@169.254.1.2"},
206 {"git@127.0.0.1", isolationPodman, "git@gitbay.test:2022", "git@gitbay.test:2022"}, 207 {"git@127.0.0.1", isolationPodman, "git@gitbay.test:2022", "git@169.254.1.2:2022"},
207 {"git@localhost", isolationPodman, "git@gitbay.org", "git@gitbay.org"}, 208 {"git@127.0.0.1", isolationPodman, "git@[2001:db8::1]:2022", "git@169.254.1.2:2022"},
208 {"git@127.0.0.1", isolationPodman, "", "git@127.0.0.1"}, 209 {"git@127.0.0.1", isolationPodman, "git@2001:db8::1", "git@169.254.1.2"},
209 {"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"}, 210 {"git@localhost", isolationPodman, "git@gitbay.org", "git@169.254.1.2"},
210 {"gitbay.org", isolationPodman, "git@gitbay.org", "gitbay.org"}, 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@gitbay.org"},
215 {"git@127.0.0.1", isolationNone, "", "git@127.0.0.1"},
216 {"git@gitbay.org", isolationPodman, "git@other.test", "git@other.test"},
217 {"git@gitbay.org", isolationPodman, "", "git@gitbay.org"},
218 {"gitbay.org", isolationPodman, "", "gitbay.org"},
211 } { 219 } {
212 r := &runner{remote: tc.remote, isolation: tc.isolation} 220 r := &runner{remote: tc.remote, isolation: tc.isolation}
213 if got := r.buildSSH(tc.public); got != tc.want { 221 if got := r.buildSSH(tc.public); got != tc.want {
@@ -217,7 +225,8 @@ func TestStepEnvCarriesInstanceAddress(t *testing.T) {
217} 225}
218 226
219// Only a runner that polls over loopback shares an address a build could 227// Only a runner that polls over loopback shares an address a build could
220// connect from, so only its builds lose the host-loopback mapping (#260). 228// connect from, so only its builds state --no-map-gw rather than rely on
229// podman's default (#260).
221func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) { 230func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) {
222 for _, tc := range []struct { 231 for _, tc := range []struct {
223 remote string 232 remote string
cmd/gitbay-runner/main.go +38 −17
@@ -16,6 +16,7 @@ import (
16 "io" 16 "io"
17 "io/fs" 17 "io/fs"
18 "log" 18 "log"
19 "net"
19 "os" 20 "os"
20 "os/exec" 21 "os/exec"
21 "os/signal" 22 "os/signal"
@@ -496,29 +497,49 @@ func (r *runner) loopbackRemote() bool {
496 return host == "127.0.0.1" || host == "localhost" || host == "::1" 497 return host == "127.0.0.1" || host == "localhost" || host == "::1"
497} 498}
498 499
499// buildSSH is the instance's ssh destination as a build reaches it. A 500// hostAddr is the address a podman build under pasta reaches the host
500// runner polling over loopback keeps its podman builds off the host's 501// at: pasta's --map-guest-addr, which podman sets to this address and
501// loopback (buildNetwork), so they get the instance's public destination 502// which pasta translates to the host's public address.
502// from the claim. Any other remote is a real host elsewhere and works as 503const hostAddr = "169.254.1.2"
503// it is, and under -isolation none a build runs on the host itself. 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. Otherwise a build uses the claim's
511// destination, or -remote when the claim carries none.
504func (r *runner) buildSSH(public string) string { 512func (r *runner) buildSSH(public string) string {
505 if r.isolation == isolationPodman && r.loopbackRemote() && public != "" { 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 if public != "" {
506 return public 529 return public
507 } 530 }
508 return r.remote 531 return r.remote
509} 532}
510 533
511// buildNetwork is the podman network option for a build. pasta maps the 534// buildNetwork is the podman network option for a build on the daemon's
512// container's gateway address to the host's loopback, and a build's 535// host. A build reaches the host at hostAddr, which pasta translates to
513// connection through it arrives from 127.0.0.1 — the address a runner on 536// the host's public address, and cannot reach the host's loopback, so
514// the daemon's host polls from. The SSH auth limiter counts failures per 537// none of its connections arrive from 127.0.0.1, the address the runner
515// source address, so a build sharing the runner's could throttle its 538// polls from; the SSH auth limiter counts failures per source address
516// polling (#260). --no-map-gw removes the mapping: the build reaches the 539// (#260). podman passes --no-map-gw to pasta by default; it is stated
517// host only at its public address, as any client on the internet does, 540// here so the build's view of the host does not depend on that default.
518// and keeps its outbound access. The host's nftables table 541// The host's nftables table (deploy/gitbay-runner-egress.nft) limits
519// (deploy/gitbay-runner-egress.nft) then limits it to 22, 80 and 443 542// what a build reaches on the host to 22, 80 and 443.
520// there; it cannot tell a build from the runner by uid, so it leaves
521// 127.0.0.1:22 open, and this flag is what keeps builds off it.
522func (r *runner) buildNetwork() []string { 543func (r *runner) buildNetwork() []string {
523 if !r.loopbackRemote() { 544 if !r.loopbackRemote() {
524 return nil 545 return nil
deploy/gitbay-runner-egress.nft +10 −4
@@ -17,14 +17,20 @@
17# outbound internet access, trusted or not (go mod download needs it). 17# outbound internet access, trusted or not (go mod download needs it).
18# 18#
19# What ci-runner may reach on this host: 19# What ci-runner may reach on this host:
20# 127.0.0.1:22 the forge over loopback, for the runner. Builds do 20# 127.0.0.1:22 the forge over loopback, for the runner. This
21# not reach loopback at all: the runner starts them 21# table cannot close it to builds. A build reaches
22# with pasta's gateway mapping off (--no-map-gw). 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.
23# loopback :53 the host's resolver, for when the host's nameserver 28# loopback :53 the host's resolver, for when the host's nameserver
24# is a loopback address. That pasta forwards a 29# is a loopback address. That pasta forwards a
25# build's DNS there is to be confirmed from inside a 30# build's DNS there is to be confirmed from inside a
26# build by runbook R3, not assumed. 31# build by runbook R3, not assumed.
27# public 22/80/443 the forge, as anyone on the internet reaches it. 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.
28# Everything else is rejected: the admin sshd on 2222 on every address, 34# Everything else is rejected: the admin sshd on 2222 on every address,
29# and any service bound to loopback. -isolation none builds run as the 35# and any service bound to loopback. -isolation none builds run as the
30# same user and get the same rule. 36# same user and get the same rule.