Commit 2258972d20
Verified · cmc
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 | ||
| 956 | 956 | restarting the runner: =127.0.0.1:22= and the public 22 must answer, |
| 957 | 957 | 2222 must not. The table limits the runner's user to =127.0.0.1:22=, |
| 958 | 958 | DNS on loopback, and 22, 80 and 443 on the host's public address; the |
| 959 | Threat-Model page says why. A restart of =nftables.service= flushes it; | |
| 960 | =systemctl reload gitbay-runner-egress= restores it. | |
| 959 | Threat-Model page says why. | |
| 960 | ||
| 961 | That 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 | |
| 964 | starts each build under =builds/trusted= or =builds/untrusted= in its | |
| 965 | service cgroup, and the table closes the host's loopback to every | |
| 966 | build, limits an untrusted build to TCP 80 and 443 and DNS, and closes | |
| 967 | private ranges to both (the CI page has the table). nftables resolves a | |
| 968 | cgroup path to its id when the table loads, and the service cgroup is | |
| 969 | new on every start, so the drop-in's =ExecStartPre= creates the two | |
| 970 | cgroups, hands them to =ci-runner= and loads the table before the | |
| 971 | runner starts; a table that does not load stops the start. =make | |
| 972 | deploy-runner= checks the file's syntax before the restart and that the | |
| 973 | table is loaded after it. If =/etc/resolv.conf= names a nameserver in a | |
| 974 | private range, allow it in the file first or builds resolve nothing. | |
| 975 | ||
| 976 | A restart of =nftables.service= flushes both tables; | |
| 977 | =systemctl reload gitbay-runner-egress= restores both. | |
| 978 | ||
| 979 | Checking the tables on a running host, during a build: | |
| 980 | ||
| 981 | #+begin_src sh | |
| 982 | nft list table inet gitbay_builds # counters on the reject rules | |
| 983 | for 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 | ||
| 987 | A pasta process anywhere else — the runner's own =runner= cgroup, a | |
| 988 | user slice — means the builds table does not see that build's traffic | |
| 989 | and only the first table applies. Then run | |
| 990 | =deploy/runner-auth-flood-test.sh= on the scratch repository (below), | |
| 991 | once as a push and once with =--untrusted=; it prints what the build | |
| 992 | reached and fails if the runner was locked out. | |
| 993 | ||
| 994 | To take the builds table out: delete the three =ExecStartPre= lines | |
| 995 | that name =builds= from the drop-in, =systemctl daemon-reload=, and | |
| 996 | =nft destroy table inet gitbay_builds=. The runner needs no restart, | |
| 997 | and builds keep the first table. | |
| 961 | 998 | |
| 962 | 999 | The drop-in sets =NoNewPrivileges=no=, without which rootless podman |
| 963 | 1000 | cannot 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 | 67 | * Host firewall |
| 68 | 68 | |
| 69 | 69 | =deploy/cloud-init.yaml= opens 22, 80, 443 and 2222 inbound with ufw. |
| 70 | Outbound traffic is not restricted, including from CI containers. See | |
| 71 | [[file:10-Known-Gaps.org][10. Known gaps]]. | |
| 70 | Outbound traffic from the host is not restricted. CI builds are, by | |
| 71 | two nftables tables on the runner's host: trusted builds keep the | |
| 72 | internet, untrusted ones get TCP 80 and 443 and DNS, and neither | |
| 73 | reaches private ranges or the host's loopback beyond DNS (the CI wiki page, #260). | |
| 72 | 74 | |
| 73 | 75 | * Change path |
| 74 | 76 | |
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +1 −1
| @@ -24,7 +24,7 @@ | ||
| 24 | 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 | 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 | 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 | 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 | 29 | | TB9 | user content → browser | Markdown and Org bodies, READMEs, filenames | HTML sanitised (=ugcHTML=, =internal/httpd/web.go=, bluemonday); CSP =script-src 'none'= | |
| 30 | 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 | 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 | 70 | | Secrets | env file 0600 outside the workspace, or =--env NAME= for multi-line values | |
| 71 | 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 | 73 | | Shutdown | SIGTERM stops claiming and drains in-flight builds; the unit uses =KillMode=mixed= | |
| 74 | 74 | |
| 75 | 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 | 88 | | No secrets for untrusted builds | in place | =internal/control/build.go= | |
| 89 | 89 | | Runner limited to attached repositories | in place | =runnerMayBuild= (=build.go=) | |
| 90 | 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 | 92 | | Build results reused only across equal trust | in place | =SuccessBuildForTree=, =SuccessBuildFor= (=internal/store/builds.go=) | |
| 93 | 93 | |
| 94 | 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 | 11 | | Issue | Area | Gap | Severity | |
| 12 | 12 | |-------+------------------+-----------------------------------------------------------------------+----------| |
| 13 | 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 | 15 | | #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | |
| 16 | 16 | |
| 17 | 17 | * Not filed |
| @@ -30,5 +30,5 @@ what the 2026-09-27 review found; remove a row when its issue closes. | ||
| 30 | 30 | |-----------------------------------------------------------+------------------------------------------| |
| 31 | 31 | | What is the measured recovery time? | unmeasured (#259) | |
| 32 | 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 | 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 | 78 | * What a build can reach |
| 79 | 79 | |
| 80 | Builds have outbound internet access, trusted and untrusted alike. On | |
| 81 | the runner's host an nftables table limits them to the forge's public | |
| 82 | ports 22, 80 and 443, which closes the operator's sshd. The forge is | |
| 83 | reached at the address in =GITBAY_SSH=: on a runner that polls the | |
| 84 | daemon over loopback, =169.254.1.2=, which pasta translates to the | |
| 85 | host's public address. See the Threat-Model page, "What a build can | |
| 86 | reach", 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 | ||
| 85 | An untrusted build is a merge request head from a fork. It can fetch | |
| 86 | modules and packages over HTTPS but cannot reach the forge, send mail, | |
| 87 | or 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 | |
| 90 | a build's logins never arrive from =127.0.0.1=, where the runner polls. | |
| 91 | Two nftables tables on the runner's host enforce this, one by the | |
| 92 | runner's user and one by the cgroup each build runs in; the Threat-Model | |
| 93 | page, "What a build can reach", says how and why, and the Admin page | |
| 94 | how to install them (krz/gitbay#260). A runner off the daemon's host | |
| 95 | gets none of this unless its operator adds it. | |
| 96 | ||
| 97 | To check a runner, run =deploy/runner-auth-flood-test.sh= against a | |
| 98 | scratch repository, once as a push and once with =--untrusted=: the | |
| 99 | build probes what it reaches, then fails SSH logins with an expired key | |
| 100 | until the limiter locks its address, and the script checks that the | |
| 101 | runner still reported the build and kept polling. | |
| 87 | 102 | |
| 88 | 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 | 203 | already has rather than naming anything on the internet. On an |
| 204 | 204 | instance with open registration that is the difference between a |
| 205 | 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 | |
| 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 | |
| 218 | 222 | =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public |
| 219 | 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 | |
| 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 | |
| 225 | 233 | counts failures per source address and, once an address is over the |
| 226 | 234 | 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=. | |
| 231 | 243 | |
| 232 | 244 | Under =-isolation none=, anything a step can do as the runner's user a |
| 233 | 245 | pushed =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 | 577 | =ssh ssh://$GITBAY_SSH …=, which work in either form. A job that |
| 578 | 578 | talks back to the instance — a release asset, a comment, a push to a |
| 579 | 579 | pages branch — uses =$GITBAY_SSH= with a key it holds as a secret; |
| 580 | the build's container has no key of its own. Two things about that | |
| 580 | the build's container has no key of its own. On the forge's own runner a | |
| 581 | build from a fork's merge request cannot reach the instance at all, and | |
| 582 | reaches the internet only over HTTPS, HTTP and DNS (CI page). Two things about that | |
| 581 | 583 | container: a secret with newlines (a private key) arrives intact, and |
| 582 | 584 | =ssh= expands =~= from the passwd entry, =/root=, not from =$HOME=, |
| 583 | 585 | which 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 | 15 | recovered and the elapsed time. The Admin wiki's Restore drill |
| 16 | 16 | section lists what to restore, what to check and where to record it |
| 17 | 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 | 31 | * v1.37.0 — 2026-09-29 |
| 20 | 32 | |