Commit 9809a86bfc

9809a86bfc25e83d970364d7855f33d1fa7ad5a9

parent: 23e979a2af

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 07:25 UTC

runner host: builds reach only the forge's public ports on it

Ref #260

Layout: unified · split

Makefile +10 −2
@@ -71,14 +71,22 @@ deploy-runner: preflight
71 $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(RUNNER_BIN) ./cmd/gitbay-runner 71 $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(RUNNER_BIN) ./cmd/gitbay-runner
72 @echo "==> pushing runner to $(HOST)" 72 @echo "==> pushing runner to $(HOST)"
73 ./deploy/copy.sh $(HOST) $(PORT) $(RUNNER_BIN) /usr/local/bin/gitbay-runner.new 73 ./deploy/copy.sh $(HOST) $(PORT) $(RUNNER_BIN) /usr/local/bin/gitbay-runner.new
74 ssh -p $(PORT) root@$(HOST) 'mkdir -p /etc/systemd/system/gitbay-runner.service.d' 74 ssh -p $(PORT) root@$(HOST) 'mkdir -p /etc/systemd/system/gitbay-runner.service.d /etc/gitbay-runner'
75 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner.override.conf /etc/systemd/system/gitbay-runner.service.d/override.conf 75 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner.override.conf /etc/systemd/system/gitbay-runner.service.d/override.conf
76 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.service /etc/systemd/system/gitbay-runner-prune.service 76 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.service /etc/systemd/system/gitbay-runner-prune.service
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
79 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.service /etc/systemd/system/gitbay-runner-egress.service
80 @echo "==> loading the egress rule"
81 ssh -p $(PORT) root@$(HOST) 'set -eu; \
82 nft -c -f /etc/gitbay-runner/egress.nft; \
83 systemctl daemon-reload; \
84 systemctl enable gitbay-runner-egress.service; \
85 systemctl reload-or-restart gitbay-runner-egress.service'
86 ssh -p $(PORT) root@$(HOST) 'sh -s' < deploy/runner-egress-check.sh
78 ssh -p $(PORT) root@$(HOST) 'set -eu; \ 87 ssh -p $(PORT) root@$(HOST) 'set -eu; \
79 chmod 755 /usr/local/bin/gitbay-runner.new; \ 88 chmod 755 /usr/local/bin/gitbay-runner.new; \
80 mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \ 89 mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \
81 systemctl daemon-reload; \
82 systemctl enable --now gitbay-runner-prune.timer; \ 90 systemctl enable --now gitbay-runner-prune.timer; \
83 systemctl restart gitbay-runner; \ 91 systemctl restart gitbay-runner; \
84 systemctl --no-pager --lines=3 status gitbay-runner; \ 92 systemctl --no-pager --lines=3 status gitbay-runner; \
deploy/gitbay-runner-egress.nft added +59
@@ -0,0 +1,59 @@
1#!/usr/sbin/nft -f
2# Host egress for CI builds (#260). Loaded by gitbay-runner-egress.service,
3# which gitbay-runner.service requires, so the runner does not start
4# without it. `make deploy-runner` installs it as
5# /etc/gitbay-runner/egress.nft.
6#
7# Under rootless podman with pasta, a build's connections are made by
8# pasta on the host, from sockets owned by the runner's user, ci-runner.
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
11# reaches on this host, and the runner needs little: 127.0.0.1:22, to
12# poll, clone and stream logs.
13#
14# 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# oifname "lo". Traffic to other hosts is not matched: builds keep
17# outbound internet access, trusted or not (go mod download needs it).
18#
19# What ci-runner may reach on this host:
20# 127.0.0.1:22 the forge over loopback, for the runner. Builds do
21# not reach loopback at all: the runner starts them
22# with pasta's gateway mapping off (--no-map-gw).
23# loopback :53 the host's resolver, which pasta forwards a
24# build's DNS to when the host's nameserver is a
25# loopback address.
26# public 22/80/443 the forge, as anyone on the internet reaches it.
27# Everything else is rejected: the admin sshd on 2222 on every address,
28# and any service bound to loopback. -isolation none builds run as the
29# same user and get the same rule.
30#
31# The account name is resolved when the file is loaded. A restart of
32# nftables.service (flush ruleset) removes this table; `systemctl
33# reload gitbay-runner-egress` puts it back.
34#
35# The first line creates the table if it is missing, so the delete never
36# fails; the file then replaces it in one transaction, and a reload never
37# leaves a moment without the rule. The uid match sits in the base
38# chain's one rule rather than in a `!=` accept, because a packet with no
39# socket (a reset the kernel sends) matches neither `==` nor `!=` on
40# skuid and would otherwise fall through to the reject.
41
42table inet gitbay_runner
43delete table inet gitbay_runner
44
45table inet gitbay_runner {
46 chain output {
47 type filter hook output priority filter; policy accept;
48 oifname "lo" meta skuid "ci-runner" jump host
49 }
50
51 chain host {
52 ip daddr 127.0.0.1 tcp dport 22 accept
53 ip daddr 127.0.0.0/8 meta l4proto { tcp, udp } th dport 53 accept
54 ip6 daddr ::1 meta l4proto { tcp, udp } th dport 53 accept
55 ip daddr != 127.0.0.0/8 tcp dport { 22, 80, 443 } accept
56 ip6 daddr != ::1 tcp dport { 22, 80, 443 } accept
57 counter reject
58 }
59}
deploy/gitbay-runner-egress.service added +26
@@ -0,0 +1,26 @@
1# Loads the CI runner's host egress rule (#260,
2# deploy/gitbay-runner-egress.nft). gitbay-runner.service requires this
3# unit, so the runner starts only with the rule in force; stopping this
4# unit removes the table and stops the runner with it.
5#
6# Ordered after nftables.service and ufw.service: either may rewrite the
7# ruleset at boot, and nftables.service's default config starts with
8# flush ruleset. A missing unit in After= is ignored.
9#
10# Reload re-reads the file and replaces the table in one transaction; it
11# does not restart the runner, which a restart of this unit would
12# (Requires= propagates restarts). `make deploy-runner` reloads.
13[Unit]
14Description=Host egress rule for CI builds
15After=nftables.service ufw.service
16Before=gitbay-runner.service
17
18[Service]
19Type=oneshot
20RemainAfterExit=yes
21ExecStart=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
22ExecReload=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
23ExecStop=/usr/sbin/nft delete table inet gitbay_runner
24
25[Install]
26WantedBy=multi-user.target
deploy/gitbay-runner.override.conf +9
@@ -22,6 +22,15 @@
22# container store lives under the runner's home, which ProtectSystem 22# container store lives under the runner's home, which ProtectSystem
23# would otherwise make read-only. Prepare the host with 23# would otherwise make read-only. Prepare the host with
24# deploy/runner-podman-setup.sh before deploying a runner that isolates. 24# deploy/runner-podman-setup.sh before deploying a runner that isolates.
25[Unit]
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
28# forge's public 22, 80 and 443 for builds, nothing else. Required, so
29# the runner does not start without it: a table that failed to load must
30# not mean builds reach the admin sshd.
31Requires=gitbay-runner-egress.service
32After=gitbay-runner-egress.service
33
25[Service] 34[Service]
26# The runner polls as a non-admin account with a runner-scoped key, and 35# The runner polls as a non-admin account with a runner-scoped key, and
27# claims only the repositories that key is attached to (`repo runner 36# claims only the repositories that key is attached to (`repo runner
deploy/runner-egress-check.sh added +34
@@ -0,0 +1,34 @@
1#!/bin/sh
2# Check the CI runner's host egress rule (#260) as the runner's user:
3# the forge over loopback on 22 must answer (the runner polls there),
4# and the admin sshd on 2222 must not, on loopback or the public
5# address. `make deploy-runner` runs this after loading the rule and
6# before restarting the runner, and stops on a failure.
7#
8# ssh -p 2222 root@bay1 'sh -s' < deploy/runner-egress-check.sh
9set -eu
10
11RUNNER_USER="${RUNNER_USER:-ci-runner}"
12# hostname -I lists the host's addresses, IPv4 first on bay1; the first
13# is the public one there.
14public=$(hostname -I | awk '{print $1}')
15
16probe() {
17 su -s /bin/bash "$RUNNER_USER" -c "timeout 5 bash -c 'exec 3<>/dev/tcp/$1/$2'" 2>/dev/null
18}
19
20nft list table inet gitbay_runner >/dev/null
21
22for dest in 127.0.0.1:22 "$public:22"; do
23 if ! probe "${dest%:*}" "${dest##*:}"; then
24 echo "$RUNNER_USER cannot reach $dest: the egress rule would stop the runner" >&2
25 exit 1
26 fi
27done
28for dest in 127.0.0.1:2222 "$public:2222"; do
29 if probe "${dest%:*}" "${dest##*:}"; then
30 echo "$RUNNER_USER reaches $dest: the egress rule is not in force" >&2
31 exit 1
32 fi
33done
34echo "egress for $RUNNER_USER: 127.0.0.1:22 and $public:22 open, 2222 refused"
deploy/runner-podman-setup.sh +10
@@ -28,6 +28,16 @@ if ! command -v podman >/dev/null 2>&1; then
28fi 28fi
29podman --version 29podman --version
30 30
31# nft loads the runner's host egress rule (#260,
32# deploy/gitbay-runner-egress.nft), which `make deploy-runner` ships and
33# the runner's unit requires. Without nft the runner does not start.
34echo "==> installing nftables"
35if ! command -v nft >/dev/null 2>&1; then
36 apt-get update
37 DEBIAN_FRONTEND=noninteractive apt-get install -y nftables
38fi
39nft --version
40
31# Rootless podman maps container uids into a range delegated to the user. 41# Rootless podman maps container uids into a range delegated to the user.
32# Without these the runner's `podman run` fails with a mapping error. 42# Without these the runner's `podman run` fails with a mapping error.
33echo "==> subuid/subgid for $RUNNER_USER" 43echo "==> subuid/subgid for $RUNNER_USER"