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.
6767* What a build can reach
6868
6969Builds have outbound internet access, trusted and untrusted alike. On
70the runner's host they reach only the forge's public ports 22, 80 and
71443: not the host's loopback, not the operator's sshd. The forge is
72reached at its public address, the one in =GITBAY_SSH=. See the
73Threat-Model page, "What a build can reach", for how and why
74(krz/gitbay#260).
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).
7576
7677* The table
7778
.gitbay/wiki/Threat-Model.org +20 −17
@@ -195,26 +195,29 @@ runner, polling over SSH, clones the commit and runs its steps.
195195 curated set and arbitrary code from a registry nobody vetted.
196196- *What a build can reach.* Outbound internet, trusted or not: a fork's
197197 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
199 as anyone on the internet reaches them. Two layers keep it there. A
200 runner that polls the daemon over loopback starts its containers with
201 pasta's gateway mapping off, so a build does not reach the host's
202 loopback, and =GITBAY_SSH= names the public address; that keeps the
203 runner's source address, =127.0.0.1=, one no build connects from. And
204 an nftables table (=deploy/gitbay-runner-egress.nft=) rejects every
205 connection the runner's user makes to the host's own addresses except
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
206208 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
207209 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
209211 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
211 is what closes it to builds. The runner does not start without the
212 table. The SSH auth limiter counts failures per source address and,
213 once an address is over the limit, refuses every key from it until
214 the window passes, the runner's included; with registration open or
215 by invite an unknown key never counts (krz/gitbay#260). Under
216 =-isolation none= a build runs on the host and shares its loopback;
217 the table still applies, since it runs as the same user.
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.
218221
219222Under =-isolation none=, anything a step can do as the runner's user a
220223pushed =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;
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=,
567the instance's public address, from a runner elsewhere and from a
568container on the server's own runner alike; =git@host:port= on an
569instance whose ssh is not on 22, so use it as =ssh://$GITBAY_SSH/owner/name.git=
570or =ssh ssh://$GITBAY_SSH …=, which work in both forms). 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; from a
568runner elsewhere, the instance's public address (=git@gitbay.org=).
569Either carries =:port= when the instance's ssh is not on 22, so use it
570as =ssh://$GITBAY_SSH/owner/name.git= or =ssh ssh://$GITBAY_SSH …=,
571which work in both forms. A job that
571572talks back to the instance — a release asset, a comment, a push to a
572573pages branch — uses =$GITBAY_SSH= with a key it holds as a secret;
573574the 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
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- Builds lose host loopback and reach only the forge's public 22, 80
137 and 443 on the host. Deploy gitbayd, then the runner, after
138 validating on a scratch repository per the CI page. (#260)
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)
139145
140146* v1.36.0 — 2026-09-23
141147
cmd/gitbay-runner/env_test.go +21 −12
@@ -191,23 +191,31 @@ func TestSplitEnvKeepsMultilineOutOfTheFile(t *testing.T) {
191191}
192192
193193// 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
195// builds off the host's loopback, so they get the instance's public
196// destination from the claim, port included when it is not 22; any other
197// remote is used as it is (#260).
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 the claim's destination, or its own remote without one (#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 }
203204 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@gitbay.org"},
206 {"git@127.0.0.1", isolationPodman, "git@gitbay.test:2022", "git@gitbay.test:2022"},
207 {"git@localhost", isolationPodman, "git@gitbay.org", "git@gitbay.org"},
208 {"git@127.0.0.1", isolationPodman, "", "git@127.0.0.1"},
209 {"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"},
210 {"gitbay.org", isolationPodman, "git@gitbay.org", "gitbay.org"},
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@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"},
211219 } {
212220 r := &runner{remote: tc.remote, isolation: tc.isolation}
213221 if got := r.buildSSH(tc.public); got != tc.want {
@@ -217,7 +225,8 @@ func TestStepEnvCarriesInstanceAddress(t *testing.T) {
217225}
218226
219227// 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).
221230func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) {
222231 for _, tc := range []struct {
223232 remote string
cmd/gitbay-runner/main.go +38 −17
@@ -16,6 +16,7 @@ import (
1616 "io"
1717 "io/fs"
1818 "log"
19 "net"
1920 "os"
2021 "os/exec"
2122 "os/signal"
@@ -496,29 +497,49 @@ func (r *runner) loopbackRemote() bool {
496497 return host == "127.0.0.1" || host == "localhost" || host == "::1"
497498}
498499
499// buildSSH is the instance's ssh destination as a build reaches it. A
500// runner polling over loopback keeps its podman builds off the host's
501// loopback (buildNetwork), so they get the instance's public destination
502// from the claim. Any other remote is a real host elsewhere and works as
503// it is, and under -isolation none a build runs on the host itself.
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. Otherwise a build uses the claim's
511// destination, or -remote when the claim carries none.
504512func (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 != "" {
506529 return public
507530 }
508531 return r.remote
509532}
510533
511// buildNetwork is the podman network option for a build. pasta maps the
512// container's gateway address to the host's loopback, and a build's
513// connection through it arrives from 127.0.0.1 — the address a runner on
514// the daemon's host polls from. The SSH auth limiter counts failures per
515// source address, so a build sharing the runner's could throttle its
516// polling (#260). --no-map-gw removes the mapping: the build reaches the
517// host only at its public address, as any client on the internet does,
518// and keeps its outbound access. The host's nftables table
519// (deploy/gitbay-runner-egress.nft) then limits it 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.
534// buildNetwork is the podman network option for a build on the daemon's
535// host. A build reaches the host at hostAddr, which pasta translates to
536// the host's public address, and cannot reach the host's loopback, so
537// none of its connections arrive from 127.0.0.1, the address the runner
538// polls from; the SSH auth limiter counts failures per source address
539// (#260). podman passes --no-map-gw to pasta by default; it is stated
540// here so the build's view of the host does not depend on that default.
541// The host's nftables table (deploy/gitbay-runner-egress.nft) limits
542// what a build reaches on the host to 22, 80 and 443.
522543func (r *runner) buildNetwork() []string {
523544 if !r.loopbackRemote() {
524545 return nil
deploy/gitbay-runner-egress.nft +10 −4
@@ -17,14 +17,20 @@
1717# outbound internet access, trusted or not (go mod download needs it).
1818#
1919# What ci-runner may reach on this host:
20# 127.0.0.1:22 the forge over loopback, for the runner. Builds do
21# not reach loopback at all: the runner starts them
22# with pasta's gateway mapping off (--no-map-gw).
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.
2328# loopback :53 the host's resolver, for when the host's nameserver
2429# is a loopback address. That pasta forwards a
2530# build's DNS there is to be confirmed from inside a
2631# 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.
2834# Everything else is rejected: the admin sshd on 2222 on every address,
2935# and any service bound to loopback. -isolation none builds run as the
3036# same user and get the same rule.