Commit 9809a86bfc
Verified · cmc
Layout: unified · split
Makefile +10 −2
| @@ -71,14 +71,22 @@ deploy-runner: preflight | ||
| 71 | 71 | $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(RUNNER_BIN) ./cmd/gitbay-runner |
| 72 | 72 | @echo "==> pushing runner to $(HOST)" |
| 73 | 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 | 75 | ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner.override.conf /etc/systemd/system/gitbay-runner.service.d/override.conf |
| 76 | 76 | ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.service /etc/systemd/system/gitbay-runner-prune.service |
| 77 | 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 | 87 | ssh -p $(PORT) root@$(HOST) 'set -eu; \ |
| 79 | 88 | chmod 755 /usr/local/bin/gitbay-runner.new; \ |
| 80 | 89 | mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \ |
| 81 | systemctl daemon-reload; \ | |
| 82 | 90 | systemctl enable --now gitbay-runner-prune.timer; \ |
| 83 | 91 | systemctl restart gitbay-runner; \ |
| 84 | 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 | ||
| 42 | table inet gitbay_runner | |
| 43 | delete table inet gitbay_runner | |
| 44 | ||
| 45 | table 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] | |
| 14 | Description=Host egress rule for CI builds | |
| 15 | After=nftables.service ufw.service | |
| 16 | Before=gitbay-runner.service | |
| 17 | ||
| 18 | [Service] | |
| 19 | Type=oneshot | |
| 20 | RemainAfterExit=yes | |
| 21 | ExecStart=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft | |
| 22 | ExecReload=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft | |
| 23 | ExecStop=/usr/sbin/nft delete table inet gitbay_runner | |
| 24 | ||
| 25 | [Install] | |
| 26 | WantedBy=multi-user.target | |
deploy/gitbay-runner.override.conf +9
| @@ -22,6 +22,15 @@ | ||
| 22 | 22 | # container store lives under the runner's home, which ProtectSystem |
| 23 | 23 | # would otherwise make read-only. Prepare the host with |
| 24 | 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. | |
| 31 | Requires=gitbay-runner-egress.service | |
| 32 | After=gitbay-runner-egress.service | |
| 33 | ||
| 25 | 34 | [Service] |
| 26 | 35 | # The runner polls as a non-admin account with a runner-scoped key, and |
| 27 | 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 | |
| 9 | set -eu | |
| 10 | ||
| 11 | RUNNER_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. | |
| 14 | public=$(hostname -I | awk '{print $1}') | |
| 15 | ||
| 16 | probe() { | |
| 17 | su -s /bin/bash "$RUNNER_USER" -c "timeout 5 bash -c 'exec 3<>/dev/tcp/$1/$2'" 2>/dev/null | |
| 18 | } | |
| 19 | ||
| 20 | nft list table inet gitbay_runner >/dev/null | |
| 21 | ||
| 22 | for 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 | |
| 27 | done | |
| 28 | for 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 | |
| 33 | done | |
| 34 | echo "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 | ||
| 28 | 28 | fi |
| 29 | 29 | podman --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. | |
| 34 | echo "==> installing nftables" | |
| 35 | if ! command -v nft >/dev/null 2>&1; then | |
| 36 | apt-get update | |
| 37 | DEBIAN_FRONTEND=noninteractive apt-get install -y nftables | |
| 38 | fi | |
| 39 | nft --version | |
| 40 | ||
| 31 | 41 | # Rootless podman maps container uids into a range delegated to the user. |
| 32 | 42 | # Without these the runner's `podman run` fails with a mapping error. |
| 33 | 43 | echo "==> subuid/subgid for $RUNNER_USER" |