Commit 4be4bd5917

4be4bd5917a9487f980c0af7ef15e099c18487bf

parent: 89708196e4

Verified · cmc

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

runner host: build egress by cgroup, narrower for untrusted builds

Ref #260

Layout: unified · split

Makefile +5
@@ -77,9 +77,13 @@ deploy-runner: preflight
77 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.timer /etc/systemd/system/gitbay-runner-prune.timer 77 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.timer /etc/systemd/system/gitbay-runner-prune.timer
78 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.nft /etc/gitbay-runner/egress.nft 78 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.nft /etc/gitbay-runner/egress.nft
79 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.service /etc/systemd/system/gitbay-runner-egress.service 79 ./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
80 @echo "==> loading the egress rule" 81 @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.
81 ssh -p $(PORT) root@$(HOST) 'set -eu; \ 84 ssh -p $(PORT) root@$(HOST) 'set -eu; \
82 nft -c -f /etc/gitbay-runner/egress.nft; \ 85 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; \
83 systemctl daemon-reload; \ 87 systemctl daemon-reload; \
84 systemctl enable gitbay-runner-egress.service; \ 88 systemctl enable gitbay-runner-egress.service; \
85 systemctl reload-or-restart gitbay-runner-egress.service' 89 systemctl reload-or-restart gitbay-runner-egress.service'
@@ -89,5 +93,6 @@ deploy-runner: preflight
89 mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \ 93 mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \
90 systemctl enable --now gitbay-runner-prune.timer; \ 94 systemctl enable --now gitbay-runner-prune.timer; \
91 systemctl restart gitbay-runner; \ 95 systemctl restart gitbay-runner; \
96 nft list table inet gitbay_builds >/dev/null; \
92 systemctl --no-pager --lines=3 status gitbay-runner; \ 97 systemctl --no-pager --lines=3 status gitbay-runner; \
93 systemctl --no-pager list-timers gitbay-runner-prune.timer' 98 systemctl --no-pager list-timers gitbay-runner-prune.timer'
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 @@
9# nftables sees them exactly as it sees the runner's own ssh, so this 9# nftables sees them exactly as it sees the runner's own ssh, so this
10# table cannot tell a build from its runner. It limits what that user 10# table cannot tell a build from its runner. It limits what that user
11# reaches on this host, and the runner needs little: 127.0.0.1:22, to 11# 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.
13# 14#
14# Every packet to one of the host's own addresses, loopback or public, 15# Every packet to one of the host's own addresses, loopback or public,
15# leaves through lo, so the output hook sees host-bound traffic as 16# leaves through lo, so the output hook sees host-bound traffic as
deploy/gitbay-runner-egress.service +7
@@ -13,6 +13,11 @@
13# 13#
14# Stop uses destroy, which succeeds when the table is already gone (a 14# Stop uses destroy, which succeeds when the table is already gone (a
15# flush ruleset removes it); delete would fail and leave the unit failed. 15# 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 those
19# cgroups exist, so after a restart of nftables.service one reload puts
20# both tables back.
16[Unit] 21[Unit]
17Description=Host egress rule for CI builds 22Description=Host egress rule for CI builds
18After=nftables.service ufw.service 23After=nftables.service ufw.service
@@ -23,7 +28,9 @@ Type=oneshot
23RemainAfterExit=yes 28RemainAfterExit=yes
24ExecStart=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft 29ExecStart=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
25ExecReload=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft 30ExecReload=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
31ExecReload=/bin/sh -c 'if [ -d /sys/fs/cgroup/system.slice/gitbay-runner.service/builds/untrusted ]; then exec /usr/sbin/nft -f /etc/gitbay-runner/builds.nft; fi'
26ExecStop=/usr/sbin/nft destroy table inet gitbay_runner 32ExecStop=/usr/sbin/nft destroy table inet gitbay_runner
33ExecStop=/usr/sbin/nft destroy table inet gitbay_builds
27 34
28[Install] 35[Install]
29WantedBy=multi-user.target 36WantedBy=multi-user.target
deploy/gitbay-runner.override.conf +13 −1
@@ -25,7 +25,8 @@
25[Unit] 25[Unit]
26# The host egress rule (#260, gitbay-runner-egress.nft) limits what this 26# The host egress rule (#260, gitbay-runner-egress.nft) limits what this
27# unit's user reaches on the host: 127.0.0.1:22 for the runner, the 27# 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
29# the runner does not start without it: a table that failed to load must 30# the runner does not start without it: a table that failed to load must
30# not mean builds reach the admin sshd. 31# not mean builds reach the admin sshd.
31Requires=gitbay-runner-egress.service 32Requires=gitbay-runner-egress.service
@@ -71,6 +72,17 @@ OOMPolicy=continue
71# start joins its namespaces — including a /tmp that no longer exists. 72# start joins its namespaces — including a /tmp that no longer exists.
72# End it with the service. 73# End it with the service.
73ExecStopPost=-/usr/bin/pkill -u ci-runner -x catatonit 74ExecStopPost=-/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
74# On stop the runner drains: it claims nothing more and finishes the 86# On stop the runner drains: it claims nothing more and finishes the
75# build in flight, then exits. Give it long enough — the per-build limit 87# build in flight, then exits. Give it long enough — the per-build limit
76# is 45m plus half a minute of report retries — before systemd kills it. 88# is 45m plus half a minute of report retries — before systemd kills it.