Commit 2258972d20

2258972d20d2f6edc42ba43d471fdc77852bfaf3

parent: b5ea7bcf97

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 03:38 UTC

wiki, changelog: build egress by trust

Ref #260

Layout: unified · split

.gitbay/wiki/Admin.org +39 −2
@@ -956,8 +956,45 @@ unit, and runs =deploy/runner-egress-check.sh= as =ci-runner= before
956restarting the runner: =127.0.0.1:22= and the public 22 must answer, 956restarting the runner: =127.0.0.1:22= and the public 22 must answer,
9572222 must not. The table limits the runner's user to =127.0.0.1:22=, 9572222 must not. The table limits the runner's user to =127.0.0.1:22=,
958DNS on loopback, and 22, 80 and 443 on the host's public address; the 958DNS on loopback, and 22, 80 and 443 on the host's public address; the
959Threat-Model page says why. A restart of =nftables.service= flushes it; 959Threat-Model page says why.
960=systemctl reload gitbay-runner-egress= restores it. 960
961That table cannot tell a build from the runner, since both run as
962=ci-runner=. =deploy/gitbay-runner-builds.nft=, shipped as
963=/etc/gitbay-runner/builds.nft=, matches by cgroup instead: the runner
964starts each build under =builds/trusted= or =builds/untrusted= in its
965service cgroup, and the table closes the host's loopback to every
966build, limits an untrusted build to TCP 80 and 443 and DNS, and closes
967private ranges to both (the CI page has the table). nftables resolves a
968cgroup path to its id when the table loads, and the service cgroup is
969new on every start, so the drop-in's =ExecStartPre= creates the two
970cgroups, hands them to =ci-runner= and loads the table before the
971runner starts; a table that does not load stops the start. =make
972deploy-runner= checks the file's syntax before the restart and that the
973table is loaded after it. If =/etc/resolv.conf= names a nameserver in a
974private range, allow it in the file first or builds resolve nothing.
975
976A restart of =nftables.service= flushes both tables;
977=systemctl reload gitbay-runner-egress= restores both.
978
979Checking the tables on a running host, during a build:
980
981#+begin_src sh
982nft list table inet gitbay_builds # counters on the reject rules
983for p in $(pgrep -u ci-runner pasta); do cat /proc/$p/cgroup; done
984# 0::/system.slice/gitbay-runner.service/builds/trusted/build-<id>
985#+end_src
986
987A pasta process anywhere else — the runner's own =runner= cgroup, a
988user slice — means the builds table does not see that build's traffic
989and only the first table applies. Then run
990=deploy/runner-auth-flood-test.sh= on the scratch repository (below),
991once as a push and once with =--untrusted=; it prints what the build
992reached and fails if the runner was locked out.
993
994To take the builds table out: delete the three =ExecStartPre= lines
995that name =builds= from the drop-in, =systemctl daemon-reload=, and
996=nft destroy table inet gitbay_builds=. The runner needs no restart,
997and builds keep the first table.
961 998
962The drop-in sets =NoNewPrivileges=no=, without which rootless podman 999The drop-in sets =NoNewPrivileges=no=, without which rootless podman
963cannot call =newuidmap= and the runner refuses to start. That is a 1000cannot call =newuidmap= and the runner refuses to start. That is a
.gitbay/wiki/Architecture/03-Deployment.org +4 −2
@@ -67,8 +67,10 @@ a database check.
67* Host firewall 67* Host firewall
68 68
69=deploy/cloud-init.yaml= opens 22, 80, 443 and 2222 inbound with ufw. 69=deploy/cloud-init.yaml= opens 22, 80, 443 and 2222 inbound with ufw.
70Outbound traffic is not restricted, including from CI containers. See 70Outbound traffic from the host is not restricted. CI builds are, by
71[[file:10-Known-Gaps.org][10. Known gaps]]. 71two nftables tables on the runner's host: trusted builds keep the
72internet, untrusted ones get TCP 80 and 443 and DNS, and neither
73reaches private ranges or the host's loopback beyond DNS (the CI wiki page, #260).
72 74
73* Change path 75* Change path
74 76
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +1 −1
@@ -24,7 +24,7 @@
24| 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=) | 24| 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=) |
25| 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=) | 25| 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=) |
26| 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=) | 26| 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; outbound is open; on the host only the forge's public ports (#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; private ranges and the host's loopback (but DNS) closed; trusted: internet open, host public 22/80/443; untrusted: internet TCP 80/443 and DNS, no host (#260) |
28| TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) | 28| TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) |
29| TB9 | user content → browser | Markdown and Org bodies, READMEs, filenames | HTML sanitised (=ugcHTML=, =internal/httpd/web.go=, bluemonday); CSP =script-src 'none'= | 29| TB9 | user content → browser | Markdown and Org bodies, READMEs, filenames | HTML sanitised (=ugcHTML=, =internal/httpd/web.go=, bluemonday); CSP =script-src 'none'= |
30| TB10| Z6 → everything | host shell | operator SSH on 2222, keys only, fail2ban; append-only offsite backup credentials | 30| 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
@@ -69,7 +69,7 @@ Who may do what:
69| Build home | trusted: =<workdir>/trusted-home/<owner>/<name>=, one per repository, persistent; untrusted: =<workdir>/build-<id>-home=, removed with the build (=main.go=) | 69| Build home | trusted: =<workdir>/trusted-home/<owner>/<name>=, one per repository, persistent; untrusted: =<workdir>/build-<id>-home=, removed with the build (=main.go=) |
70| Secrets | env file 0600 outside the workspace, or =--env NAME= for multi-line values | 70| Secrets | env file 0600 outside the workspace, or =--env NAME= for multi-line values |
71| Resources | per-build cgroup with =memory.max= and =cpu.max= written by the runner; unit-level =MemoryMax=6G=, =CPUQuota=300%= | 71| Resources | per-build cgroup with =memory.max= and =cpu.max= written by the runner; unit-level =MemoryMax=6G=, =CPUQuota=300%= |
72| 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) | 72| Network | pasta; a loopback runner's builds run with =--no-map-gw= (=main.go=); host limited by user (=gitbay-runner-egress.nft=) and by build cgroup, trusted or untrusted (=gitbay-runner-builds.nft=, #260) |
73| Shutdown | SIGTERM stops claiming and drains in-flight builds; the unit uses =KillMode=mixed= | 73| Shutdown | SIGTERM stops claiming and drains in-flight builds; the unit uses =KillMode=mixed= |
74 74
75* Integrations 75* Integrations
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -88,7 +88,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
88| No secrets for untrusted builds | in place | =internal/control/build.go= | 88| No secrets for untrusted builds | in place | =internal/control/build.go= |
89| Runner limited to attached repositories | in place | =runnerMayBuild= (=build.go=) | 89| Runner limited to attached repositories | in place | =runnerMayBuild= (=build.go=) |
90| Build images fixed by the operator | in place | =--pull=never= | 90| Build images fixed by the operator | in place | =--pull=never= |
91| Build network egress restricted | partial | host: loopback closed, public 22/80/443 only (=gitbay-runner-egress.nft=); internet outbound open by decision (#260) | 91| Build network egress restricted | partial | by build cgroup (=gitbay-runner-builds.nft=): host loopback (but DNS) and private ranges closed; trusted: host public 22/80/443, internet open by decision; untrusted: internet TCP 80/443 and DNS only. Not yet measured from a build (#260) |
92| Build results reused only across equal trust | in place | =SuccessBuildForTree=, =SuccessBuildFor= (=internal/store/builds.go=) | 92| Build results reused only across equal trust | in place | =SuccessBuildForTree=, =SuccessBuildFor= (=internal/store/builds.go=) |
93 93
94** Availability and operations 94** Availability and operations
.gitbay/wiki/Architecture/10-Known-Gaps.org +2 −2
@@ -11,7 +11,7 @@ what the 2026-09-27 review found; remove a row when its issue closes.
11| Issue | Area | Gap | Severity | 11| Issue | Area | Gap | Severity |
12|-------+------------------+-----------------------------------------------------------------------+----------| 12|-------+------------------+-----------------------------------------------------------------------+----------|
13| #259 | Recovery | No restore has been exercised; the procedure and tooling (=admin restore-drill=, =backup --verify=) are in place, the clean-host drill is pending | high | 13| #259 | Recovery | No restore has been exercised; the procedure and tooling (=admin restore-drill=, =backup --verify=) are in place, the clean-host drill is pending | high |
14| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 14| #260 | CI network | Separation and egress tables written; not yet measured from a build on bay1 (=deploy/runner-auth-flood-test.sh=) | medium |
15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | 15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16 16
17* Not filed 17* Not filed
@@ -30,5 +30,5 @@ what the 2026-09-27 review found; remove a row when its issue closes.
30|-----------------------------------------------------------+------------------------------------------| 30|-----------------------------------------------------------+------------------------------------------|
31| What is the measured recovery time? | unmeasured (#259) | 31| What is the measured recovery time? | unmeasured (#259) |
32| How many concurrent clones does the host sustain? | unmeasured (#262) | 32| How many concurrent clones does the host sustain? | unmeasured (#262) |
33| What can a build reach on the host's network? | configuration inspected, reachability untested (#260) | 33| What can a build reach on the host's network? | policy on the CI page; reachability untested until the flood test runs (#260) |
34| Have the collaboration features been used by independent users? | no; one human user, tests only | 34| Have the collaboration features been used by independent users? | no; one human user, tests only |
.gitbay/wiki/CI.org +22 −7
@@ -77,13 +77,28 @@ failed one.
77 77
78* What a build can reach 78* What a build can reach
79 79
80Builds have outbound internet access, trusted and untrusted alike. On 80| Build | Internet | Runner's host | Private ranges |
81the runner's host an nftables table limits them to the forge's public 81|-----------+------------------+---------------------------------------------+----------------|
82ports 22, 80 and 443, which closes the operator's sshd. The forge is 82| trusted | open | forge's public 22, 80, 443; DNS on loopback | closed |
83reached at the address in =GITBAY_SSH=: on a runner that polls the 83| untrusted | TCP 80, 443; DNS | DNS on loopback | closed |
84daemon over loopback, =169.254.1.2=, which pasta translates to the 84
85host's public address. See the Threat-Model page, "What a build can 85An untrusted build is a merge request head from a fork. It can fetch
86reach", for how and why (krz/gitbay#260). 86modules and packages over HTTPS but cannot reach the forge, send mail,
87or open ssh elsewhere. The forge is reached at the address in
88=GITBAY_SSH=: on a runner that polls the daemon over loopback,
89=169.254.1.2=, which pasta translates to the host's public address, so
90a build's logins never arrive from =127.0.0.1=, where the runner polls.
91Two nftables tables on the runner's host enforce this, one by the
92runner's user and one by the cgroup each build runs in; the Threat-Model
93page, "What a build can reach", says how and why, and the Admin page
94how to install them (krz/gitbay#260). A runner off the daemon's host
95gets none of this unless its operator adds it.
96
97To check a runner, run =deploy/runner-auth-flood-test.sh= against a
98scratch repository, once as a push and once with =--untrusted=: the
99build probes what it reaches, then fails SSH logins with an expired key
100until the limiter locks its address, and the script checks that the
101runner still reported the build and kept polling.
87 102
88* The table 103* The table
89 104
.gitbay/wiki/Threat-Model.org +33 −21
@@ -203,31 +203,43 @@ runner, polling over SSH, clones the commit and runs its steps.
203 already has rather than naming anything on the internet. On an 203 already has rather than naming anything on the internet. On an
204 instance with open registration that is the difference between a 204 instance with open registration that is the difference between a
205 curated set and arbitrary code from a registry nobody vetted. 205 curated set and arbitrary code from a registry nobody vetted.
206- *What a build can reach.* Outbound internet, trusted or not: a fork's 206- *What a build can reach.* A trusted build has the internet; an
207 merge request to a Go repository has to fetch its modules. On the 207 untrusted one — a fork's merge request head — has TCP 80 and 443 and
208 runner's host, the rules below limit it to the forge's public ports 208 DNS, enough to fetch modules and packages. Neither reaches private
209 22, 80 and 443. Under pasta a build's container holds the host's own 209 address ranges (RFC 1918, CGNAT, link-local, ULA). On the runner's
210 public address, and pasta translates =169.254.1.2= (its 210 host a trusted build reaches the forge's public ports 22, 80 and 443
211 =--map-guest-addr=) to that address. A runner that polls the daemon 211 and the resolver on loopback; an untrusted build reaches only the
212 over loopback starts its containers with =--network 212 resolver. Under pasta a build's container holds the host's own public
213 pasta:--no-map-gw=, which is podman's default stated explicitly, and 213 address, and pasta translates =169.254.1.2= (its =--map-guest-addr=)
214 gives them =GITBAY_SSH= at =169.254.1.2=, with the forge's port when 214 to that address. A runner that polls the daemon over loopback starts
215 it is not 22. The runner's source address is =127.0.0.1=. An nftables 215 its containers with =--network pasta:--no-map-gw=, which is podman's
216 table (=deploy/gitbay-runner-egress.nft=) rejects every connection 216 default stated explicitly, and gives them =GITBAY_SSH= at
217 the runner's user makes to the host's own addresses except 217 =169.254.1.2=, with the forge's port when it is not 22. The runner's
218 source address is =127.0.0.1=; a trusted build's is the host's public
219 address. Two nftables tables enforce this. The first
220 (=deploy/gitbay-runner-egress.nft=) matches the runner's user and
221 rejects every connection it makes to the host's own addresses except
218 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public 222 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
219 address: the operator's sshd on 2222 and anything bound to loopback 223 address: the operator's sshd on 2222 and anything bound to loopback
220 are closed to it. Under rootless podman a build's connections are 224 are closed. Under rootless podman a build's connections are made by
221 made by pasta as the runner's user, so the table cannot tell a build 225 pasta as that same user, so this table cannot tell a build from its
222 from its runner and leaves =127.0.0.1:22= open; that no build reaches 226 runner. The second (=deploy/gitbay-runner-builds.nft=) can: the runner
223 the host's loopback is measured from inside a build by runbook R3. 227 starts every podman process for a build, pasta included, inside
224 The runner does not start without the table. The SSH auth limiter 228 =builds/trusted/build-<id>= or =builds/untrusted/build-<id>= under its
229 service cgroup, and the table matches sockets by those cgroups
230 (=socket cgroupv2=). It closes the host's loopback, =127.0.0.1:22=
231 included, to every build except for DNS, and applies the per-trust
232 rules above. The runner does not start without either table. The SSH auth limiter
225 counts failures per source address and, once an address is over the 233 counts failures per source address and, once an address is over the
226 limit, refuses every key from it until the window passes, the 234 limit, refuses every key from it until the window passes, the
227 runner's included; with registration open or by invite an unknown 235 runner's included; an unknown key counts only with registration
228 key never counts (krz/gitbay#260). Under =-isolation none= a build 236 closed, an expired key always (krz/gitbay#260). No build shares
229 runs on the host and shares its loopback; the table still applies, 237 =127.0.0.1= with the runner, and an untrusted build cannot reach sshd
230 since it runs as the same user. 238 at all. Trusted builds share the public address with one another, so
239 one that fails logins can throttle another's push for a minute. Under
240 =-isolation none= a build runs on the host in the runner's cgroup and
241 shares its loopback; only the first table applies, and such a runner
242 must not take =-untrusted=.
231 243
232Under =-isolation none=, anything a step can do as the runner's user a 244Under =-isolation none=, anything a step can do as the runner's user a
233pushed =ci.yml= can do. Under podman a step is confined to its 245pushed =ci.yml= can do. Under podman a step is confined to its
.gitbay/wiki/Users.org +3 −1
@@ -577,7 +577,9 @@ the destination that runner polls (its =-remote=, such as
577=ssh ssh://$GITBAY_SSH …=, which work in either form. A job that 577=ssh ssh://$GITBAY_SSH …=, which work in either form. A job that
578talks back to the instance — a release asset, a comment, a push to a 578talks back to the instance — a release asset, a comment, a push to a
579pages branch — uses =$GITBAY_SSH= with a key it holds as a secret; 579pages branch — uses =$GITBAY_SSH= with a key it holds as a secret;
580the build's container has no key of its own. Two things about that 580the build's container has no key of its own. On the forge's own runner a
581build from a fork's merge request cannot reach the instance at all, and
582reaches the internet only over HTTPS, HTTP and DNS (CI page). Two things about that
581container: a secret with newlines (a private key) arrives intact, and 583container: a secret with newlines (a private key) arrives intact, and
582=ssh= expands =~= from the passwd entry, =/root=, not from =$HOME=, 584=ssh= expands =~= from the passwd entry, =/root=, not from =$HOME=,
583which is the build home — so keep an ssh config in the workspace and 585which is the build home — so keep an ssh config in the workspace and
CHANGELOG.org +12
@@ -15,6 +15,18 @@ anything beyond "replace the binary and restart" is needed.
15 recovered and the elapsed time. The Admin wiki's Restore drill 15 recovered and the elapsed time. The Admin wiki's Restore drill
16 section lists what to restore, what to check and where to record it 16 section lists what to restore, what to check and where to record it
17 (#259). 17 (#259).
18- The runner starts each podman build under =builds/trusted= or
19 =builds/untrusted= in its service cgroup, and a second nftables
20 table (=deploy/gitbay-runner-builds.nft=) matches build traffic by
21 that cgroup: no build reaches the host's loopback beyond DNS, or a
22 private range; a trusted build keeps the internet and the forge's public 22,
23 80 and 443; an untrusted build gets TCP 80 and 443 and DNS, and not
24 the forge. The runner's drop-in creates the cgroups and loads the
25 table on every start, and the start fails without it. =make
26 deploy-runner= installs it. =deploy/runner-auth-flood-test.sh= runs
27 the scratch-repository test: a build failing SSH logins must not
28 lock the runner out. Run it before pointing the runner back at real
29 repositories; the Admin page has the steps and the rollback. (#260)
18 30
19* v1.37.0 — 2026-09-29 31* v1.37.0 — 2026-09-29
20 32