runner: build egress by trust class; auth-flood scratch test !519

merged merged by cmc on 2026-09-29 03:57 UTC · krz/gitbay:runner-egress-260 into main

22 files changed, +602 −62

Layout: unified · split

.gitbay/wiki/Admin.org +61 −2
@@ -956,8 +956,67 @@ 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
979The egress unit's stop and the rollback below use =nft destroy=,
980which needs nftables 1.0.8 or later; check =nft --version= on a new
981host.
982
983Checking the tables on a running host, during a build (the flood test
984below waits a minute before its first probe, for this):
985
986#+begin_src sh
987nft list table inet gitbay_builds # counters on the reject rules
988for p in $(pgrep -u ci-runner pasta); do cat /proc/$p/cgroup; done
989# 0::/system.slice/gitbay-runner.service/builds/trusted/build-<id>
990#+end_src
991
992A pasta process anywhere else — the runner's own =runner= cgroup, a
993user slice — means the builds table does not see that build's traffic
994and only the first table applies. Run
995=deploy/runner-auth-flood-test.sh= on the scratch repository (below),
996once as a push and once with =--untrusted=. It fails if the runner was
997locked out or if the build log lacks the lines only a working table
998produces. The authoritative proof that pasta's sockets are in the
999build's cgroup is the untrusted run: =169.254.1.2:22= and
1000=github.com:22= refused, all twelve logins refused, and the counters on
1001the =untrusted= chain's rejects rising. The first table lets
1002=ci-runner= reach both of those addresses.
1003
1004To take the builds table out, on the host:
1005
1006#+begin_src sh
1007sed -i '/^ExecStartPre=+.*builds/d' /etc/systemd/system/gitbay-runner.service.d/override.conf
1008rm /etc/gitbay-runner/builds.nft
1009systemctl daemon-reload
1010nft destroy table inet gitbay_builds
1011#+end_src
1012
1013The runner needs no restart. It still places builds under
1014=builds/trusted= and =builds/untrusted=, but with the file gone neither
1015a runner start nor =systemctl reload gitbay-runner-egress= loads the
1016table again, and builds keep the first table and =--no-map-gw=. A
1017runner that takes =-untrusted= or polls over loopback refuses to start
1018without build cgroups at all, since the table would match nothing. The
1019next =make deploy-runner= installs the file and the lines again.
9611020
9621021The drop-in sets =NoNewPrivileges=no=, without which rootless podman
9631022cannot 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 +23 −7
@@ -77,13 +77,29 @@ 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, and that the build's
102log shows what the table allowed and refused.
87103
88104* The table
89105
.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 +14
@@ -15,6 +15,20 @@ 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. A runner
26 with =-untrusted= or a loopback =-remote= refuses to start without
27 build cgroups, which the table needs to match anything. =make
28 deploy-runner= installs it. =deploy/runner-auth-flood-test.sh= runs
29 the scratch-repository test: a build failing SSH logins must not
30 lock the runner out. Run it before pointing the runner back at real
31 repositories; the Admin page has the steps and the rollback. (#260)
1832
1933* v1.37.0 — 2026-09-29
2034
Makefile +5
@@ -77,9 +77,13 @@ deploy-runner: preflight
7777 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.timer /etc/systemd/system/gitbay-runner-prune.timer
7878 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.nft /etc/gitbay-runner/egress.nft
7979 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.service /etc/systemd/system/gitbay-runner-egress.service
80 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-builds.nft /etc/gitbay-runner/builds.nft
8081 @echo "==> loading the egress rule"
82 @# builds.nft names the runner's class cgroups, which may not exist yet;
83 @# its syntax is checked against system.slice, which always does.
8184 ssh -p $(PORT) root@$(HOST) 'set -eu; \
8285 nft -c -f /etc/gitbay-runner/egress.nft; \
86 sed "s|level 4 \"system.slice/gitbay-runner.service/builds/[a-z]*\"|level 1 \"system.slice\"|" /etc/gitbay-runner/builds.nft | nft -c -f /dev/stdin; \
8387 systemctl daemon-reload; \
8488 systemctl enable gitbay-runner-egress.service; \
8589 systemctl reload-or-restart gitbay-runner-egress.service'
@@ -89,5 +93,6 @@ deploy-runner: preflight
8993 mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \
9094 systemctl enable --now gitbay-runner-prune.timer; \
9195 systemctl restart gitbay-runner; \
96 nft list table inet gitbay_builds >/dev/null; \
9297 systemctl --no-pager --lines=3 status gitbay-runner; \
9398 systemctl --no-pager list-timers gitbay-runner-prune.timer'
cmd/gitbay-runner/cgroup.go +33
@@ -17,6 +17,39 @@ import (
1717// process for that build from inside it with podman's own cgroup handling
1818// off. What follows is the portable half: parsing and the file writes.
1919
20// buildClasses are the cgroups a build is placed under, by the claim's
21// trust flag. deploy/gitbay-runner-builds.nft matches a build's sockets,
22// pasta's included, by these two cgroups (#260), so the names are fixed.
23var buildClasses = []string{"trusted", "untrusted"}
24
25// buildCgroupDir is build id's cgroup under the runner's builds cgroup.
26func buildCgroupDir(builds string, id int64, trusted bool) string {
27 class := buildClasses[1]
28 if trusted {
29 class = buildClasses[0]
30 }
31 return filepath.Join(builds, class, fmt.Sprintf("build-%d", id))
32}
33
34// buildCgroupsRequired names what makes build cgroups mandatory, or ""
35// when a podman runner may run its builds in its own service cgroup.
36// Limits that cannot be applied are refused, not dropped: a runner that
37// accepted -memory and ran uncapped is what #188 was. A runner taking
38// untrusted builds, or polling over loopback on the daemon's host, is
39// the shape the builds nftables table guards, and that table matches
40// builds by these cgroups; without them it matches nothing (#260).
41func buildCgroupsRequired(memory, cpus string, untrusted, loopback bool) string {
42 switch {
43 case memory != "" || cpus != "":
44 return "-memory/-cpus"
45 case untrusted:
46 return "-untrusted"
47 case loopback:
48 return "a loopback -remote"
49 }
50 return ""
51}
52
2053// memoryBytes parses podman's memory units — a whole number with an
2154// optional b, k, m or g suffix — into bytes.
2255func memoryBytes(s string) (int64, error) {
cmd/gitbay-runner/cgroup_linux.go +21 −6
@@ -17,7 +17,8 @@ import (
1717// Delegate=yes hands the runner its service cgroup; the runner parks
1818// itself in a leaf so the service cgroup can enable controllers for
1919// children (a cgroup may hold processes or controller-enabled children,
20// not both), and creates one child per build under builds/.
20// not both), and creates one child per build under builds/trusted or
21// builds/untrusted.
2122type buildCgroups struct {
2223 builds string // <service cgroup>/builds
2324}
@@ -25,7 +26,11 @@ type buildCgroups struct {
2526const cgroupControllers = "+cpu +memory +pids"
2627
2728// prepareBuildCgroups moves the runner into <own>/runner, enables the
28// controllers on its original cgroup, and creates builds/. It fails
29// controllers on its original cgroup, and creates builds/ with a child
30// per trust class. The unit's drop-in may have created those before the
31// runner started, to load the builds nftables table against them; they
32// are used as found, never recreated, since the table holds their ids.
33// It fails
2934// where the cgroup is not writable, which is a unit without
3035// Delegate=yes; the caller decides whether that is fatal.
3136func prepareBuildCgroups() (*buildCgroups, error) {
@@ -55,13 +60,23 @@ func prepareBuildCgroups() (*buildCgroups, error) {
5560 if err := os.WriteFile(filepath.Join(builds, "cgroup.subtree_control"), []byte(cgroupControllers), 0o644); err != nil {
5661 return nil, fmt.Errorf("enabling controllers on %s: %w", builds, err)
5762 }
63 for _, class := range buildClasses {
64 dir := filepath.Join(builds, class)
65 if err := os.MkdirAll(dir, 0o755); err != nil {
66 return nil, err
67 }
68 if err := os.WriteFile(filepath.Join(dir, "cgroup.subtree_control"), []byte(cgroupControllers), 0o644); err != nil {
69 return nil, fmt.Errorf("enabling controllers on %s: %w", dir, err)
70 }
71 }
5872 return &buildCgroups{builds: builds}, nil
5973}
6074
61// create makes the cgroup for one build with its limits written, and
62// returns its path and an open directory fd for placing processes.
63func (c *buildCgroups) create(id int64, memory, cpus string) (string, *os.File, error) {
64 dir := filepath.Join(c.builds, fmt.Sprintf("build-%d", id))
75// create makes the cgroup for one build under its trust class with its
76// limits written, and returns its path and an open directory fd for
77// placing processes.
78func (c *buildCgroups) create(id int64, trusted bool, memory, cpus string) (string, *os.File, error) {
79 dir := buildCgroupDir(c.builds, id, trusted)
6580 if err := os.Mkdir(dir, 0o755); err != nil {
6681 return "", nil, err
6782 }
cmd/gitbay-runner/cgroup_other.go +1 −1
@@ -14,7 +14,7 @@ func prepareBuildCgroups() (*buildCgroups, error) {
1414 return nil, errors.New("build cgroups need Linux")
1515}
1616
17func (c *buildCgroups) create(id int64, memory, cpus string) (string, *os.File, error) {
17func (c *buildCgroups) create(id int64, trusted bool, memory, cpus string) (string, *os.File, error) {
1818 return "", nil, errors.New("build cgroups need Linux")
1919}
2020
cmd/gitbay-runner/cgroup_test.go +67
@@ -1,8 +1,10 @@
11package main
22
33import (
4 "fmt"
45 "os"
56 "path/filepath"
7 "strings"
68 "testing"
79)
810
@@ -90,3 +92,68 @@ func TestWriteLimits(t *testing.T) {
9092 t.Error("a bad memory limit was accepted")
9193 }
9294}
95
96// A build's cgroup sits under its trust class, which is what the builds
97// nftables table matches on (#260).
98func TestBuildCgroupDir(t *testing.T) {
99 builds := "/sys/fs/cgroup/system.slice/gitbay-runner.service/builds"
100 if got := buildCgroupDir(builds, 7, true); got != builds+"/trusted/build-7" {
101 t.Errorf("trusted: %s", got)
102 }
103 if got := buildCgroupDir(builds, 8, false); got != builds+"/untrusted/build-8" {
104 t.Errorf("untrusted: %s", got)
105 }
106}
107
108// The builds table and the drop-in that creates its cgroups and loads it
109// name the same class cgroups the runner places builds in. A rename on
110// one side alone would leave builds unmatched, with only the uid table
111// between them and the host.
112func TestBuildsTableNamesTheClassCgroups(t *testing.T) {
113 read := func(name string) string {
114 t.Helper()
115 b, err := os.ReadFile(filepath.Join("..", "..", "deploy", name))
116 if err != nil {
117 t.Fatal(err)
118 }
119 return string(b)
120 }
121 const unit = "system.slice/gitbay-runner.service"
122 table, dropin := read("gitbay-runner-builds.nft"), read("gitbay-runner.override.conf")
123 for _, class := range buildClasses {
124 match := fmt.Sprintf(`socket cgroupv2 level 4 "%s/builds/%s" jump %s`, unit, class, class)
125 if !strings.Contains(table, match) {
126 t.Errorf("gitbay-runner-builds.nft lacks %q", match)
127 }
128 dir := "/sys/fs/cgroup/" + unit + "/builds/" + class
129 if !strings.Contains(dropin, dir) {
130 t.Errorf("the drop-in does not create %s", dir)
131 }
132 }
133 if !strings.Contains(dropin, "ExecStartPre=+/usr/sbin/nft -f /etc/gitbay-runner/builds.nft") {
134 t.Error("the drop-in does not load the builds table")
135 }
136}
137
138// Without build cgroups a podman runner may carry on only where nothing
139// depends on them: no limits, no untrusted builds, and not on the
140// daemon's host, where the builds table matches by cgroup (#260).
141func TestBuildCgroupsRequired(t *testing.T) {
142 cases := []struct {
143 memory, cpus string
144 untrusted, loopback bool
145 want string
146 }{
147 {"", "", false, false, ""},
148 {"6g", "", false, false, "-memory/-cpus"},
149 {"", "3", false, false, "-memory/-cpus"},
150 {"", "", true, false, "-untrusted"},
151 {"", "", false, true, "a loopback -remote"},
152 {"", "", true, true, "-untrusted"},
153 }
154 for _, c := range cases {
155 if got := buildCgroupsRequired(c.memory, c.cpus, c.untrusted, c.loopback); got != c.want {
156 t.Errorf("buildCgroupsRequired(%q, %q, %v, %v) = %q, want %q", c.memory, c.cpus, c.untrusted, c.loopback, got, c.want)
157 }
158 }
159}
cmd/gitbay-runner/isolate.go +1 −1
@@ -134,7 +134,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
134134 // from the runner's cgroup would run the step outside the limit.
135135 var cgroupFD *os.File
136136 if r.cgroups != nil {
137 dir, f, err := r.cgroups.create(j.ID, r.memory, r.cpus)
137 dir, f, err := r.cgroups.create(j.ID, j.Trusted, r.memory, r.cpus)
138138 if err != nil {
139139 fmt.Fprintf(sink, "preparing the build cgroup: %v\n", err)
140140 return &failure{Reason: "preparing the build cgroup failed"}
cmd/gitbay-runner/main.go +13 −14
@@ -127,22 +127,12 @@ func main() {
127127 memory: *memory,
128128 cpus: *cpus,
129129 }
130 var cgErr error
130131 if r.isolation == isolationPodman {
131132 // Before podman runs anything: its pause process lands in the
132133 // cgroup of the first invocation, and that must be the runner's
133134 // leaf, not a build's.
134 cg, err := prepareBuildCgroups()
135 switch {
136 case err == nil:
137 r.cgroups = cg
138 case r.memory != "" || r.cpus != "":
139 // Limits that cannot be applied are refused, not dropped:
140 // a runner that accepted -memory and ran uncapped is what
141 // #188 was.
142 log.Fatalf("-memory/-cpus: build cgroups unavailable: %v", err)
143 default:
144 log.Printf("build cgroups unavailable (%v); builds run unconfined in the service cgroup", err)
145 }
135 r.cgroups, cgErr = prepareBuildCgroups()
146136 }
147137 if err := r.checkIsolation(); err != nil {
148138 // Refusing to start is the point. A runner that quietly fell back
@@ -152,6 +142,14 @@ func main() {
152142 // one of them is honest about why (#144).
153143 log.Fatalf("isolation: %v", err)
154144 }
145 if cgErr != nil {
146 // After checkIsolation, so a missing image or podman is reported
147 // as that rather than as the cgroups it would also lack.
148 if why := buildCgroupsRequired(r.memory, r.cpus, *untrusted, r.loopbackRemote()); why != "" {
149 log.Fatalf("%s: build cgroups unavailable: %v", why, cgErr)
150 }
151 log.Printf("build cgroups unavailable (%v); builds run unconfined in the service cgroup", cgErr)
152 }
155153 if *sshOpts != "" {
156154 r.sshOpts = strings.Fields(*sshOpts)
157155 }
@@ -557,8 +555,9 @@ func (r *runner) buildSSH(public string) string {
557555// polls from; the SSH auth limiter counts failures per source address
558556// (#260). podman passes --no-map-gw to pasta by default; it is stated
559557// here so the build's view of the host does not depend on that default.
560// The host's nftables table (deploy/gitbay-runner-egress.nft) limits
561// what a build reaches on the host to 22, 80 and 443.
558// The host's nftables tables (deploy/gitbay-runner-egress.nft and
559// gitbay-runner-builds.nft) limit what a build reaches on the host to
560// 22, 80 and 443, and an untrusted build to nothing but DNS.
562561func (r *runner) buildNetwork() []string {
563562 if !r.loopbackRemote() {
564563 return nil
deploy/gitbay-runner-builds.nft added +94
@@ -0,0 +1,94 @@
1#!/usr/sbin/nft -f
2# Egress for CI builds by trust (#260). Installed as
3# /etc/gitbay-runner/builds.nft by `make deploy-runner` and loaded by the
4# runner unit's ExecStartPre (gitbay-runner.override.conf), after the
5# cgroups it names exist.
6#
7# gitbay-runner-egress.nft cannot tell a build from its runner: both run
8# as ci-runner. This table can. The runner starts every podman process
9# for a build inside builds/trusted/build-<id> or
10# builds/untrusted/build-<id> under its service cgroup, and pasta,
11# which podman starts, inherits that cgroup; so every socket pasta opens
12# for a build carries it. The runner itself, its clone and its log
13# stream run in <service>/runner and never match here.
14#
15# nftables resolves a cgroup path to the cgroup's id when the table
16# loads. The runner's service cgroup is new on every start, so the drop-in
17# creates builds/trusted and builds/untrusted and loads this file on
18# every start, and the runner never recreates them. A table loaded
19# against an earlier start's cgroups matches nothing, and builds then
20# get only the uid table.
21#
22# The uid table still applies to builds; a packet must pass both. This
23# table only takes away. Both hook output with policy accept, so their
24# relative order does not matter.
25#
26# Trusted builds (a branch of the repository, with its secrets):
27# internet open, any port.
28# host, public 22, 80 and 443: the forge at GITBAY_SSH
29# (169.254.1.2, which pasta translates to the public
30# address). hutch and orgo publish over 22.
31# host, loopback 53 only: the host's resolver, where pasta forwards
32# a build's DNS when the host's nameserver is a
33# loopback address.
34# private ranges closed (RFC 1918, CGNAT, link-local, ULA).
35# Untrusted builds (a fork's merge request head, no secrets):
36# internet 80 and 443 over TCP, and 53: enough to fetch
37# modules and packages, not to send mail or reach
38# ssh elsewhere.
39# host loopback 53 only. No forge: an untrusted build has
40# no key to use there, and a failing login from it
41# would count against the host's public address.
42# private ranges closed.
43# -isolation none builds run in the runner's own cgroup and get only the
44# uid table; such a runner must not take -untrusted.
45#
46# A host whose /etc/resolv.conf names a nameserver in a private range
47# needs that address let through here, or builds resolve nothing.
48#
49# The first line creates the table if it is missing, so the delete never
50# fails; the file then replaces it in one transaction.
51
52table inet gitbay_builds
53delete table inet gitbay_builds
54
55table inet gitbay_builds {
56 set private4 {
57 type ipv4_addr
58 flags interval
59 elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16 }
60 }
61
62 set private6 {
63 type ipv6_addr
64 flags interval
65 elements = { fc00::/7, fe80::/10 }
66 }
67
68 chain output {
69 type filter hook output priority filter; policy accept;
70 socket cgroupv2 level 4 "system.slice/gitbay-runner.service/builds/trusted" jump trusted
71 socket cgroupv2 level 4 "system.slice/gitbay-runner.service/builds/untrusted" jump untrusted
72 }
73
74 chain trusted {
75 oifname "lo" ip daddr 127.0.0.0/8 meta l4proto { tcp, udp } th dport 53 return
76 oifname "lo" ip6 daddr ::1 meta l4proto { tcp, udp } th dport 53 return
77 oifname "lo" ip daddr != 127.0.0.0/8 tcp dport { 22, 80, 443 } return
78 oifname "lo" ip6 daddr != ::1 tcp dport { 22, 80, 443 } return
79 oifname "lo" counter reject
80 ip daddr @private4 counter reject
81 ip6 daddr @private6 counter reject
82 }
83
84 chain untrusted {
85 oifname "lo" ip daddr 127.0.0.0/8 meta l4proto { tcp, udp } th dport 53 return
86 oifname "lo" ip6 daddr ::1 meta l4proto { tcp, udp } th dport 53 return
87 oifname "lo" counter reject
88 ip daddr @private4 counter reject
89 ip6 daddr @private6 counter reject
90 meta l4proto { tcp, udp } th dport 53 return
91 tcp dport { 80, 443 } return
92 counter reject
93 }
94}
deploy/gitbay-runner-egress.nft +2 −1
@@ -9,7 +9,8 @@
99# nftables sees them exactly as it sees the runner's own ssh, so this
1010# table cannot tell a build from its runner. It limits what that user
1111# reaches on this host, and the runner needs little: 127.0.0.1:22, to
12# poll, clone and stream logs.
12# poll, clone and stream logs. gitbay-runner-builds.nft tells them apart
13# by cgroup and narrows this per build, trusted or not.
1314#
1415# Every packet to one of the host's own addresses, loopback or public,
1516# leaves through lo, so the output hook sees host-bound traffic as
deploy/gitbay-runner-egress.service +8
@@ -13,6 +13,12 @@
1313#
1414# Stop uses destroy, which succeeds when the table is already gone (a
1515# flush ruleset removes it); delete would fail and leave the unit failed.
16#
17# The builds table (gitbay-runner-builds.nft) is loaded by the runner's
18# own start, against its cgroups. Reload loads it again when the file is
19# installed and those cgroups exist, so after a restart of
20# nftables.service one reload puts both tables back; without the file
21# (taken out per the Admin page) the reload skips it and succeeds.
1622[Unit]
1723Description=Host egress rule for CI builds
1824After=nftables.service ufw.service
@@ -23,7 +29,9 @@ Type=oneshot
2329RemainAfterExit=yes
2430ExecStart=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
2531ExecReload=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
32ExecReload=/bin/sh -c 'if [ -f /etc/gitbay-runner/builds.nft ] && [ -d /sys/fs/cgroup/system.slice/gitbay-runner.service/builds/untrusted ]; then exec /usr/sbin/nft -f /etc/gitbay-runner/builds.nft; fi'
2633ExecStop=/usr/sbin/nft destroy table inet gitbay_runner
34ExecStop=/usr/sbin/nft destroy table inet gitbay_builds
2735
2836[Install]
2937WantedBy=multi-user.target
deploy/gitbay-runner.override.conf +13 −1
@@ -25,7 +25,8 @@
2525[Unit]
2626# The host egress rule (#260, gitbay-runner-egress.nft) limits what this
2727# unit's user reaches on the host: 127.0.0.1:22 for the runner, the
28# forge's public 22, 80 and 443 for builds, nothing else. Required, so
28# forge's public 22, 80 and 443 for builds, nothing else. The builds
29# table (ExecStartPre below) narrows that per build. Required, so
2930# the runner does not start without it: a table that failed to load must
3031# not mean builds reach the admin sshd.
3132Requires=gitbay-runner-egress.service
@@ -71,6 +72,17 @@ OOMPolicy=continue
7172# start joins its namespaces — including a /tmp that no longer exists.
7273# End it with the service.
7374ExecStopPost=-/usr/bin/pkill -u ci-runner -x catatonit
75# The builds table (#260, gitbay-runner-builds.nft) matches a build's
76# traffic by the cgroup the runner starts it in, and nftables turns a
77# cgroup path into the cgroup's id when the table loads. This unit's
78# cgroup is new on every start, so the two class cgroups are created
79# here, handed to the runner's user, and the table loaded against them,
80# before the runner starts. As root (+), so the mkdir does not depend on
81# when systemd hands the delegated cgroup to the unit's user. A table
82# that fails to load stops the start, as the uid table's Requires= does.
83ExecStartPre=+/usr/bin/mkdir -p /sys/fs/cgroup/system.slice/gitbay-runner.service/builds/trusted /sys/fs/cgroup/system.slice/gitbay-runner.service/builds/untrusted
84ExecStartPre=+/usr/bin/chown -R ci-runner:ci-runner /sys/fs/cgroup/system.slice/gitbay-runner.service/builds
85ExecStartPre=+/usr/sbin/nft -f /etc/gitbay-runner/builds.nft
7486# On stop the runner drains: it claims nothing more and finishes the
7587# build in flight, then exits. Give it long enough — the per-build limit
7688# is 45m plus half a minute of report retries — before systemd kills it.
deploy/runner-auth-flood-test.sh added +201
@@ -0,0 +1,201 @@
1#!/bin/sh
2# Scratch-repository test for #260: a build fails SSH logins while the
3# runner works, and the runner must not be locked out with it.
4#
5# Run from a machine with an admin gitbay identity, after the Admin
6# page's scratch procedure: the runner's key attached to the scratch
7# repository and the runner scoped to it with -repos. The script
8# replaces the repository's .gitbay/ci.yml.
9#
10# deploy/runner-auth-flood-test.sh cmc/runner-scratch # trusted: a push to main
11# deploy/runner-auth-flood-test.sh cmc/runner-scratch --untrusted # a merge request from a fork
12#
13# The build's logins use a git-scoped key registered with --ttl 1s and
14# expired by the time the build runs. With registration open an
15# unknown key is admitted to run register and never counts against the
16# SSH auth limiter; an expired key counts (internal/sshd/sshd.go, authenticate).
17# The key is removed from the account when the script exits.
18#
19# The step waits a minute, so the operator can find pasta's cgroup
20# (Admin page), probes what the build reaches, then makes 12 logins
21# without pause, so the limiter (ssh_auth_rate, 10 a minute per address) locks
22# the address those logins come from for most of the next minute. The
23# runner reports the result right after the step; its report retries
24# for half a minute.
25#
26# Before #260 a build reached the host's loopback through pasta, and its
27# logins arrived from 127.0.0.1, where the runner polls: the report is
28# refused ("too many authentication attempts" in the runner's journal),
29# the build is failed by the server two minutes after its log stream
30# ended, the runner's last-seen stops advancing for up to a minute, and
31# the audit log has auth.throttled for 127.0.0.1.
32#
33# With the fix, trusted: GITBAY_SSH is git@169.254.1.2, the logins are
34# denied and arrive from the host's public address, auth.throttled
35# names that address, the build succeeds and the runner keeps polling.
36# Untrusted: every login is refused by the builds table before it
37# reaches sshd, and no auth.* entry comes from the build at all.
38#
39# The script also requires lines in the build log, so a missing ssh or
40# bash in the image, or a table that matches nothing, fails rather than
41# passes. Trusted: "logins 12 denied" (every login reached sshd) and
42# 10.0.0.1:80 refused, not timed out. Untrusted: 169.254.1.2:22 and
43# github.com:22 refused and "12 refused". The untrusted lines are the
44# proof that pasta's sockets are in the build's cgroup: the uid table
45# lets ci-runner reach both.
46set -eu
47
48repo=${1:-}
49[ -n "$repo" ] || { echo "usage: $0 <owner/name> [--untrusted]" >&2; exit 2; }
50mode=trusted
51[ "${2:-}" = --untrusted ] && mode=untrusted
52host=${GITBAY_HOST:-gitbay.org}
53account=${RUNNER_ACCOUNT:-ci}
54job=flood260
55
56tmp=$(mktemp -d)
57fp=
58cleanup() {
59 if [ -n "$fp" ]; then gitbay keys remove "$fp" >/dev/null || echo "remove key $fp by hand" >&2; fi
60 fp=
61 rm -rf "$tmp"
62}
63trap cleanup EXIT
64trap 'cleanup; exit 130' INT TERM
65
66echo "==> an expired key"
67ssh-keygen -q -t ed25519 -N '' -C auth-flood-260 -f "$tmp/key"
68gitbay keys add --scope git --label auth-flood-260 --ttl 1s <"$tmp/key.pub" >/dev/null
69fp=$(ssh-keygen -lf "$tmp/key.pub" | awk '{print $2}')
70sleep 2
71
72if [ "$mode" = untrusted ]; then
73 src="${repo%/*}/${repo#*/}-fork260"
74 if ! gitbay repo show "$src" --json >/dev/null 2>&1; then
75 echo "==> forking $repo to $src"
76 gitbay repo fork "$repo" --name "${repo#*/}-fork260" >/dev/null
77 fi
78 branch="flood-260-$(date +%s)"
79else
80 src=$repo
81 branch=main
82fi
83
84echo "==> committing the $job job to $src ($branch)"
85git clone -q "ssh://git@$host/$src.git" "$tmp/repo"
86cd "$tmp/repo"
87[ "$branch" = main ] || git checkout -q -b "$branch"
88mkdir -p .gitbay
89cp "$tmp/key" .gitbay/flood.key
90cat >.gitbay/ci.yml <<EOF
91jobs:
92 $job:
93 steps:
94 - echo "GITBAY_SSH=\$GITBAY_SSH"
95 - sh .gitbay/flood.sh
96EOF
97cat >.gitbay/flood.sh <<'EOF'
98#!/bin/sh
99# Written by deploy/runner-auth-flood-test.sh (#260).
100set -u
101sleep 60
102key=/tmp/flood.key
103cp .gitbay/flood.key "$key"
104chmod 600 "$key"
105dest=${GITBAY_SSH#*@}
106host=${dest%:*}
107port=${dest##*:}
108[ "$host" = "$dest" ] && port=22
109
110probe() {
111 timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null
112 case $? in
113 0) echo "open $1:$2" ;;
114 124) echo "timeout $1:$2" ;;
115 *) echo "refused $1:$2" ;;
116 esac
117}
118getent hosts proxy.golang.org >/dev/null && echo "dns ok" || echo "dns failed"
119for t in "$host:22" "$host:80" "$host:443" "$host:2222" \
120 10.0.0.1:80 192.168.0.1:80 proxy.golang.org:443 github.com:22; do
121 probe "${t%:*}" "${t##*:}"
122done
123
124denied=0 refused=0 other=0 i=0
125while [ $i -lt 12 ]; do
126 i=$((i + 1))
127 out=$(ssh -F /dev/null -i "$key" -o IdentitiesOnly=yes -o BatchMode=yes \
128 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \
129 -p "$port" "git@$host" whoami 2>&1)
130 case $out in
131 *"Permission denied"*) denied=$((denied + 1)) ;;
132 *"Connection refused"*) refused=$((refused + 1)) ;;
133 *) other=$((other + 1)); echo "login $i: $out" ;;
134 esac
135done
136echo "logins $denied denied, $refused refused, $other other"
137EOF
138git add .gitbay
139git commit -q -m "ci: auth flood test (#260)"
140git push -q origin "$branch"
141cd - >/dev/null
142
143if [ "$mode" = untrusted ]; then
144 gitbay mr create "$repo" --source "$src:$branch" --target main --title "#260 auth flood, untrusted" >/dev/null
145fi
146
147# One gitbay call per tick: the CLI shares one connection, and a burst of
148# logins is what the limiter is for.
149echo "==> waiting for the build"
150n=
151for _ in $(seq 1 12); do
152 sleep 5
153 n=$(gitbay build list "$repo" --job "$job" --limit 1 --json | jq -r '.data | (.items // .) | .[0].number // empty')
154 [ -n "$n" ] && break
155done
156[ -n "$n" ] || { echo "no $job build queued on $repo" >&2; exit 1; }
157echo " build $n"
158status=
159for _ in $(seq 1 60); do
160 sleep 10
161 status=$(gitbay build show "$repo" "$n" --json | jq -r .data.status)
162 case $status in success | failure) break ;; esac
163done
164echo " $status"
165
166echo "==> the runner after the build"
167seen() { gitbay admin runners --json | jq -r --arg a "$account" '[.data.runners[] | select(.username == $a) | .last_seen] | max // empty'; }
168first=$(seen)
169sleep 15
170second=$(seen)
171echo " $account last seen $first, then $second"
172
173echo "==> build log"
174log=$(gitbay build log "$repo" "$n")
175printf '%s\n' "$log" | sed -n '/dns /,$p'
176
177echo "==> auth audit, last 15 minutes"
178gitbay audit --action auth. --since 15m --json |
179 jq -r '.data[] | "\(.action) \(.data | fromjson | .ip // "-")"' | sort | uniq -c
180
181echo
182fail=0
183[ "$status" = success ] || { echo "FAIL: build $n is $status; the runner could not report it"; fail=1; }
184[ -n "$second" ] && [ "$second" != "$first" ] || { echo "FAIL: the runner did not poll in 15 seconds after the build"; fail=1; }
185if gitbay audit --action auth.throttled --since 15m --json | jq -e '.data[] | select((.data | fromjson | .ip) == "127.0.0.1")' >/dev/null; then
186 echo "FAIL: 127.0.0.1, the runner's address, was throttled"
187 fail=1
188fi
189need() {
190 printf '%s\n' "$log" | grep -Eq "$1" || { echo "FAIL: the build log lacks \"$2\""; fail=1; }
191}
192if [ "$mode" = trusted ]; then
193 need 'logins +12 denied' "logins 12 denied"
194 need 'refused +10\.0\.0\.1:80( |$)' "refused 10.0.0.1:80"
195else
196 need 'refused +169\.254\.1\.2:22( |$)' "refused 169.254.1.2:22"
197 need 'refused +github\.com:22( |$)' "refused github.com:22"
198 need '12 refused' "12 refused"
199fi
200[ $fail = 0 ] && echo "PASS ($mode): the build's failed logins did not lock the runner out"
201exit $fail