The README grew alongside the tool and ended up in the order the work happened rather than the order a reader needs. Three things were wrong with it. The two-tier split was implicit, so a reader had to sort out which paragraphs applied to the host they were building. The opening defined the whole tool as "no Docker socket in job containers, and deny-by-default egress", which describes one tier only - someone building the other was told it does things it deliberately will not do. And long-form justification sat between the reader and the steps. So the README is now tier-first: pick a tier from a comparison table, then follow only that tier's half. Everything that is background rather than instruction moved to docs/reference.md, with docs/README.md indexing it and the Proxmox runbook. The runbook stays in proxmox/ beside the firewall template it explains. The tier names changed with it. `ci` and `runtime` were dejima's words for these hosts: `ci` distinguishes nothing on a tool whose whole subject is CI, and `runtime` only means something if you already know dejima's runtime tests are the workload that forced the tier to exist. They are `confined` and `privileged` now, after what a job on each is granted. The distinction that generalises is where the security boundary sits - inside the host for one, on the hypervisor for the other - and the table leads with it. `unprivileged`/`privileged` would have been the more standard pairing and was rejected: two characters apart on a flag where a misread silently produces a host that looks hardened and is not. The old --role values still work and say what they are now called rather than translating silently. examples/ is renamed to match on both sides, so no file's tier is implied by the absence of a suffix, and the config mounts in the compose files follow. Written down explicitly for the first time: the privileged tier has no egress allowlist by design. Its jobs hold the socket and run in the host's network namespace, so any filter installed there is one they can flush. Installing it anyway would leave a host that looks like it has an allowlist, which is worse than one that plainly does not. Also adds GPL-3.0-or-later to match dejima: LICENSE plus SPDX headers on the scripts. bats test/ passes, 21/21. All four --role values and an invalid one behave as intended; the compose and act_runner examples still parse.
184 lines
8.5 KiB
Plaintext
Executable File
184 lines
8.5 KiB
Plaintext
Executable File
#!/usr/bin/env bash
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
# Copyright (C) 2026 Brad Anderson
|
|
#
|
|
# Deny-by-default egress for the job network.
|
|
#
|
|
# The one thing to get right here is that a job's traffic passes through two
|
|
# different chains depending on where it is going, and filtering only the
|
|
# obvious one leaves the DNS allowlist decorative:
|
|
#
|
|
# job -> anywhere off the bridge FORWARD, so DOCKER-USER
|
|
# job -> the resolver on the INPUT, because the gateway address
|
|
# bridge gateway belongs to this host
|
|
#
|
|
# A job that can reach any resolver it likes can resolve anything it likes, so
|
|
# without the INPUT rules below it simply asks a public resolver and every
|
|
# name is allowed again.
|
|
#
|
|
# Two more positions are NOT covered by this script, and should not be
|
|
# confused with it:
|
|
#
|
|
# - Pulling the job's own image is the Docker daemon's traffic, in this
|
|
# host's namespace: that is the host's OUTPUT chain.
|
|
# - Where a job has a daemon of its own, `docker build` steps run on that
|
|
# daemon's bridge under its own policy.
|
|
#
|
|
# Reads /etc/dejima-ci/config, the same file dejima-ci-allowlist uses, so the
|
|
# bridge name is configured once rather than edited into a script. Run by
|
|
# dejima-ci-firewall.service on every Docker start - see README.md.
|
|
|
|
set -euo pipefail
|
|
|
|
PROG=${0##*/}
|
|
CONFIG=${DEJIMA_CI_CONFIG:-/etc/dejima-ci/config}
|
|
|
|
die() { echo "$PROG: $*" >&2; exit 1; }
|
|
|
|
cfg_get() {
|
|
local key="$1" line
|
|
[ -r "$CONFIG" ] || return 0
|
|
while IFS= read -r line; do
|
|
line="${line%%#*}"
|
|
[[ "$line" == *=* ]] || continue
|
|
[ "$(echo "${line%%=*}" | tr -d '[:space:]')" = "$key" ] || continue
|
|
echo "${line#*=}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
|
return 0
|
|
done < "$CONFIG"
|
|
}
|
|
|
|
# Checked by name, because the failure without it is not obvious. The first
|
|
# real command below is `ipset create`, so on a host without ipset this script
|
|
# aborts at line one under `set -e` - and the state that leaves behind looks
|
|
# almost like a working one: DNS still refuses unlisted names, because that is
|
|
# dnsmasq's doing and not the firewall's, while a job can reach any address it
|
|
# likes. "command not found" scrolling past during setup is easy to miss; a
|
|
# runner with no egress policy is not something to discover later.
|
|
for tool in ipset iptables ip6tables; do
|
|
command -v "$tool" >/dev/null 2>&1 || {
|
|
echo "$tool is not installed - these rules cannot be applied." >&2
|
|
echo "On Debian/Ubuntu: apt-get install ipset iptables" >&2
|
|
echo "" >&2
|
|
echo "Without this, DNS allowlisting still appears to work (dnsmasq refuses" >&2
|
|
echo "unlisted names on its own) while jobs have unrestricted egress." >&2
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
if [ ! -e "$CONFIG" ]; then
|
|
die "no config at $CONFIG - run install.sh to create it"
|
|
elif [ ! -r "$CONFIG" ]; then
|
|
die "$CONFIG is not readable by $(id -un) - run this as root, or with sudo"
|
|
fi
|
|
|
|
BRIDGE=$(cfg_get bridge)
|
|
GATEWAY=$(cfg_get gateway)
|
|
SET=$(cfg_get ipset)
|
|
: "${SET:=ci-allow}"
|
|
[ -n "$BRIDGE" ] || die "bridge is not set in $CONFIG - the job network's bridge name, e.g. dejima-ci0"
|
|
|
|
# One or more, space-separated. The documented "a job network per runner" tier
|
|
# needs the same rules on each bridge, and the ipset is shared either way - it
|
|
# holds destinations, not per-bridge state.
|
|
read -r -a BRIDGES <<< "$BRIDGE"
|
|
|
|
# The bridge has to exist before rules can name it. Applied at boot by a unit
|
|
# ordered after docker.service, this is the case where the network has not
|
|
# been created yet - which is a setup step that was missed rather than a
|
|
# transient, so say which.
|
|
for b in "${BRIDGES[@]}"; do
|
|
ip link show "$b" >/dev/null 2>&1 \
|
|
|| die "no interface named $b - create the job network first, or correct 'bridge' in $CONFIG"
|
|
done
|
|
|
|
# A resolver that is bound but not answering is what a recreated bridge leaves
|
|
# behind, and it is invisible: `ss` still lists the socket, and every job dies
|
|
# in apt with "Temporary failure resolving". Probing it here costs nothing and
|
|
# this script already runs on every Docker start - which is exactly when the
|
|
# bridge may have been recreated underneath it.
|
|
if [ -n "${GATEWAY:-}" ] && command -v python3 >/dev/null 2>&1; then
|
|
python3 - "$GATEWAY" <<'PROBE' || echo "$PROG: the resolver at $GATEWAY did not answer - if it is 'bound' in ss but silent, its interface was recreated under it: systemctl restart dnsmasq" >&2
|
|
import socket, struct, sys
|
|
q = b''.join(bytes([len(p)]) + p.encode() for p in "localhost.".strip('.').split('.')) + b' |