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
956956restarting the runner: =127.0.0.1:22= and the public 22 must answer,
9579572222 must not. The table limits the runner's user to =127.0.0.1:22=,
958958DNS 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;
960=systemctl reload gitbay-runner-egress= restores it.
959Threat-Model page says why.
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.
961998
962999The drop-in sets =NoNewPrivileges=no=, without which rootless podman
9631000cannot 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.
6767* Host firewall
6868
6969=deploy/cloud-init.yaml= opens 22, 80, 443 and 2222 inbound with ufw.
70Outbound traffic is not restricted, including from CI containers. See
71[[file:10-Known-Gaps.org][10. Known gaps]].
70Outbound traffic from the host is not restricted. CI builds are, by
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).
7274
7375* Change path
7476
.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; 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) |
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
@@ -69,7 +69,7 @@ Who may do what:
6969| Build home | trusted: =<workdir>/trusted-home/<owner>/<name>=, one per repository, persistent; untrusted: =<workdir>/build-<id>-home=, removed with the build (=main.go=) |
7070| Secrets | env file 0600 outside the workspace, or =--env NAME= for multi-line values |
7171| 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) |
7373| Shutdown | SIGTERM stops claiming and drains in-flight builds; the unit uses =KillMode=mixed= |
7474
7575* 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 | 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) |
9292| Build results reused only across equal trust | in place | =SuccessBuildForTree=, =SuccessBuildFor= (=internal/store/builds.go=) |
9393
9494** 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.
1111| Issue | Area | Gap | Severity |
1212|-------+------------------+-----------------------------------------------------------------------+----------|
1313| #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 |
1515| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
1616
1717* Not filed
@@ -30,5 +30,5 @@ what the 2026-09-27 review found; remove a row when its issue closes.
3030|-----------------------------------------------------------+------------------------------------------|
3131| What is the measured recovery time? | unmeasured (#259) |
3232| 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) |
3434| 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.
7777
7878* What a build can reach
7979
80Builds have outbound internet access, trusted and untrusted alike. On
81the runner's host an nftables table limits them to the forge's public
82ports 22, 80 and 443, which closes the operator's sshd. The forge is
83reached at the address in =GITBAY_SSH=: on a runner that polls the
84daemon over loopback, =169.254.1.2=, which pasta translates to the
85host's public address. See the Threat-Model page, "What a build can
86reach", for how and why (krz/gitbay#260).
80| Build | Internet | Runner's host | Private ranges |
81|-----------+------------------+---------------------------------------------+----------------|
82| trusted | open | forge's public 22, 80, 443; DNS on loopback | closed |
83| untrusted | TCP 80, 443; DNS | DNS on loopback | closed |
84
85An untrusted build is a merge request head from a fork. It can fetch
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.
87102
88103* The table
89104
.gitbay/wiki/Threat-Model.org +33 −21
@@ -203,31 +203,43 @@ runner, polling over SSH, clones the commit and runs its steps.
203203 already has rather than naming anything on the internet. On an
204204 instance with open registration that is the difference between a
205205 curated set and arbitrary code from a registry nobody vetted.
206- *What a build can reach.* Outbound internet, trusted or not: a fork's
207 merge request to a Go repository has to fetch its modules. On the
208 runner's host, the rules below limit it to the forge's public ports
209 22, 80 and 443. Under pasta a build's container holds the host's own
210 public address, and pasta translates =169.254.1.2= (its
211 =--map-guest-addr=) to that address. A runner that polls the daemon
212 over loopback starts its containers with =--network
213 pasta:--no-map-gw=, which is podman's default stated explicitly, and
214 gives them =GITBAY_SSH= at =169.254.1.2=, with the forge's port when
215 it is not 22. The runner's source address is =127.0.0.1=. An nftables
216 table (=deploy/gitbay-runner-egress.nft=) rejects every connection
217 the runner's user makes to the host's own addresses except
206- *What a build can reach.* A trusted build has the internet; an
207 untrusted one — a fork's merge request head — has TCP 80 and 443 and
208 DNS, enough to fetch modules and packages. Neither reaches private
209 address ranges (RFC 1918, CGNAT, link-local, ULA). On the runner's
210 host a trusted build reaches the forge's public ports 22, 80 and 443
211 and the resolver on loopback; an untrusted build reaches only the
212 resolver. Under pasta a build's container holds the host's own public
213 address, and pasta translates =169.254.1.2= (its =--map-guest-addr=)
214 to that address. A runner that polls the daemon over loopback starts
215 its containers with =--network pasta:--no-map-gw=, which is podman's
216 default stated explicitly, and gives them =GITBAY_SSH= at
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
218222 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
219223 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
221 made by pasta as the runner's user, so the table cannot tell a build
222 from its runner and leaves =127.0.0.1:22= open; that no build reaches
223 the host's loopback is measured from inside a build by runbook R3.
224 The runner does not start without the table. The SSH auth limiter
224 are closed. Under rootless podman a build's connections are made by
225 pasta as that same user, so this table cannot tell a build from its
226 runner. The second (=deploy/gitbay-runner-builds.nft=) can: the runner
227 starts every podman process for a build, pasta included, inside
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
225233 counts failures per source address and, once an address is over the
226234 limit, refuses every key from it until the window passes, the
227 runner's included; with registration open or by invite an unknown
228 key never counts (krz/gitbay#260). Under =-isolation none= a build
229 runs on the host and shares its loopback; the table still applies,
230 since it runs as the same user.
235 runner's included; an unknown key counts only with registration
236 closed, an expired key always (krz/gitbay#260). No build shares
237 =127.0.0.1= with the runner, and an untrusted build cannot reach sshd
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=.
231243
232244Under =-isolation none=, anything a step can do as the runner's user a
233245pushed =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
577577=ssh ssh://$GITBAY_SSH …=, which work in either form. A job that
578578talks back to the instance — a release asset, a comment, a push to a
579579pages 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
581583container: a secret with newlines (a private key) arrives intact, and
582584=ssh= expands =~= from the passwd entry, =/root=, not from =$HOME=,
583585which 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.
1515 recovered and the elapsed time. The Admin wiki's Restore drill
1616 section lists what to restore, what to check and where to record it
1717 (#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)
1830
1931* v1.37.0 — 2026-09-29
2032