Files
dejima-ci-hardener/bin/dejima-ci-allowlist
T
Brad Anderson f6156c2888 Name the tiers after what a job gets, and put the reader first
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.
2026-08-06 19:31:55 -07:00

288 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Brad Anderson
#
# dejima-ci-allowlist - render a CI job network's DNS allowlist from the
# .dejima/allowed-domains declarations of the repositories this runner serves,
# read from the branch the operator controls.
#
# The value is entirely in where it reads from. `main` got its contents through
# a pull request someone merged; a pull request branch got its contents from
# whatever pushed it. A branch that can write its own allowlist does not have
# one - so this reads over the forge API, by ref, and never from a checkout.
#
# The consequence: widening what CI can reach is a diff on main. Reviewed,
# attributable, revertible, and made in the same file and by the same act that
# widens what a sandbox session may reach.
#
# See README.md. Runs on a CI host. Never on a host that runs dejima.
set -euo pipefail
PROG=${0##*/}
CONFIG=${DEJIMA_CI_CONFIG:-/etc/dejima-ci/config}
usage() {
cat >&2 <<EOF
Usage: $PROG [render|check|show] [--config PATH]
render fetch, validate and install the allowlist, reloading the resolver
only if it changed (the default; what the timer runs)
check do everything render does except install - prints what would
change and exits 1 if it would, 0 if it would not. Exit 2 is
always a fault - bad configuration, an unreachable forge, a
malformed entry - never "something would change"
show print the allowlist currently installed
Reads $CONFIG unless --config or \$DEJIMA_CI_CONFIG says otherwise.
EOF
exit 2
}
# Exit 2, not 1. `check` uses 1 for "there is a pending change" - an ordinary
# answer, not a fault - so everything that IS a fault has to be distinguishable
# from it, or a caller cannot tell "the allowlist moved" from "the forge is
# unreachable".
die() { echo "$PROG: $*" >&2; exit 2; }
# --- config -----------------------------------------------------------------
# Parsed, never sourced. This file sits on a CI host and names a token path; a
# config format that executes is a config format that runs whatever an
# unexpected line contains.
declare -A CFG=(
[bridge]=""
[gateway]=""
[network]=""
[subnet]=""
[ipset]="ci-allow"
[forge_api]=""
[token_file]="/etc/dejima-ci/forge-token"
[repos_file]="/etc/dejima-ci/repos"
[base_file]="/etc/dejima-ci/base-domains"
[upstream]=""
[out]="/etc/dnsmasq.d/ci-allow.conf"
[ref]="main"
[reload]="systemctl reload dnsmasq"
)
read_config() {
local file="$1" line key value
# -r alone conflates two different problems with one message. The config
# lives at a root-owned path, so "no config at ..." was what a non-root
# user saw for a file that was sitting right there - sending them to
# create something that already existed.
if [ ! -e "$file" ]; then
die "no config at $file - run install.sh to create it"
elif [ ! -r "$file" ]; then
die "$file exists but is not readable by $(id -un) - it is mode $(stat -c %a "$file" 2>/dev/null || echo '?') and owned by $(stat -c %U "$file" 2>/dev/null || echo '?'). Run this as root, or with sudo."
fi
while IFS= read -r line || [ -n "$line" ]; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -n "$line" ] || continue
[[ "$line" == *=* ]] || die "malformed line in $file: $line"
key="${line%%=*}"; value="${line#*=}"
key="${key%"${key##*[![:space:]]}"}"
value="${value#"${value%%[![:space:]]*}"}"
[[ -v CFG[$key] ]] || die "unknown key in $file: $key"
CFG[$key]="$value"
done < "$file"
}
# --- preflight --------------------------------------------------------------
# Every setting and every file this run depends on, checked before any of it is
# used, and reported together.
#
# Reporting one problem at a time costs a run per problem, and the failures
# that reach an operator this way are the least legible ones: a missing
# token_file used to surface as bash's own "No such file or directory" with a
# line number, followed by "empty token" (it was absent, not empty), followed
# by "collection failed" - three messages, none of which named the setting
# that pointed there or what to put in it.
declare -A HINT=(
[forge_api]="your forge's API root, e.g. https://gitea.example.com/api/v1"
[upstream]="the resolver dnsmasq forwards ALLOWED queries to - the one that knows the answers, not this dnsmasq and not the bridge gateway. Whatever this host already resolves with: grep nameserver /etc/resolv.conf, or - if that says 127.0.0.53 - resolvectl status"
[out]="where the generated allowlist is written, e.g. /etc/dnsmasq.d/ci-allow.conf"
[ref]="the branch treated as operator-approved, normally main"
[reload]="the command that reloads the resolver, e.g. systemctl reload dnsmasq"
[token_file]="a file holding a READ-ONLY forge token, mode 0600"
[repos_file]="one owner/repo per line - the repositories allowed to widen this runner's allowlist"
[base_file]="names allowed regardless of any project declaration, one per line"
)
preflight() {
local problems=() key
for key in forge_api upstream out ref reload; do
[ -n "${CFG[$key]}" ] \
|| problems+=("$key is empty or missing in $CONFIG - ${HINT[$key]}")
done
# base_file is genuinely optional; the other two are not.
for key in token_file repos_file; do
if [ -z "${CFG[$key]}" ]; then
problems+=("$key is empty or missing in $CONFIG - ${HINT[$key]}")
elif [ ! -e "${CFG[$key]}" ]; then
problems+=("$key points at ${CFG[$key]}, which does not exist - ${HINT[$key]}")
elif [ ! -r "${CFG[$key]}" ]; then
problems+=("$key points at ${CFG[$key]}, which is not readable by $(id -un)")
elif [ "$key" = token_file ] && [ ! -s "${CFG[$key]}" ]; then
problems+=("token_file ${CFG[$key]} is empty - ${HINT[token_file]}")
fi
done
if [ -n "${CFG[base_file]}" ] && [ -e "${CFG[base_file]}" ] && [ ! -r "${CFG[base_file]}" ]; then
problems+=("base_file points at ${CFG[base_file]}, which is not readable by $(id -un)")
fi
[ ${#problems[@]} -eq 0 ] && return 0
{
printf '%s: cannot run - %d problem(s) with the configuration:\n\n' "$PROG" "${#problems[@]}"
printf ' - %s\n' "${problems[@]}"
printf '\nFix all of them, or re-run install.sh, which prompts for each.\n'
} >&2
# 2, not 1: `check` uses 1 for "there is a pending change", which is an
# ordinary answer rather than a fault. A caller - install.sh, or a timer's
# alerting - has to be able to tell those apart.
exit 2
}
# --- validation -------------------------------------------------------------
# Every name below arrives from a file in a repository - a place agent-authored
# code can write - so nothing reaches a resolver directive without matching
# this first.
#
# An unexpected line is a hard failure rather than a skipped one. A silently
# dropped entry reads as "that domain was never declared" and sends whoever
# debugs it looking in the wrong repository.
valid_domain() {
local d="$1"
[ ${#d} -le 253 ] || return 1
[[ "$d" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]]
}
# --- fetch ------------------------------------------------------------------
fetch_repo() {
local repo="$1" token="$2" body http rc=0
body=$(mktemp)
# Cleaned up explicitly rather than with a RETURN trap: without
# `set -o functrace` such a trap is not scoped to the function that set
# it, so it fires again on the caller's return - by which point $body is
# out of scope and `set -u` turns a tidy-up into a failed run that has
# already done its work correctly.
#
# Read the status, not curl's exit code: --fail collapses every HTTP error
# into 22, and the difference decides whether this run may continue. A
# repository with no declaration is ordinary; a 401 means this run does not
# know what the allowlist is and must not render one.
http=$(curl -sS --max-time 20 -o "$body" -w '%{http_code}' \
-H "Authorization: token $token" \
"${CFG[forge_api]}/repos/$repo/raw/.dejima/allowed-domains?ref=${CFG[ref]}") || rc=$?
if [ "$rc" -ne 0 ]; then
rm -f "$body"
die "could not reach the forge for $repo"
fi
case "$http" in
200) cat "$body" ;;
404) ;; # no declaration is a normal state, not an error
*) rm -f "$body"; die "unexpected HTTP $http for $repo" ;;
esac
rm -f "$body"
}
collect() {
local token repo
token=$(< "${CFG[token_file]}")
[ -n "$token" ] || die "empty token in ${CFG[token_file]}"
# The base set first: what the runner needs regardless of any project.
# Keep it narrower than dejima's built-in domains - a name judged
# reasonable for a firewalled container is a different proposition on a
# host on your network.
[ -r "${CFG[base_file]}" ] && cat "${CFG[base_file]}"
while read -r repo || [ -n "$repo" ]; do
repo="${repo%%#*}"; repo="${repo//[[:space:]]/}"
[ -n "$repo" ] || continue
fetch_repo "$repo" "$token"
done < "${CFG[repos_file]}"
}
render() {
local raw domains d out
# Collected first, filtered second, so that the two failures stay
# distinguishable. Filtering in the same pipeline makes "the forge did not
# answer" and "every declaration is empty" arrive as the same error under
# pipefail - grep exits 1 on no matches - and they send whoever reads it
# to entirely different places.
raw=$(collect) || die "collection failed; leaving the existing allowlist in place"
domains=$(printf '%s\n' "$raw" | tr -d '\r' | tr -d '[:blank:]' | grep -v '^$' | sort -u || true)
# An empty render is refused rather than installed. This is the failure
# direction that matters: an allowlist of nothing looks identical to a
# working one until a job needs something.
[ -n "$domains" ] || die "collected an empty allowlist; refusing to install it"
out=$(mktemp)
printf '# Generated by %s. Do not edit: a hand-edit is a grant nobody merged.\n' "$PROG" > "$out"
while IFS= read -r d; do
valid_domain "$d" || die "refusing a malformed entry: $d"
printf 'server=/%s/%s\n' "$d" "${CFG[upstream]}" >> "$out"
done <<< "$domains"
# Only replace a working config with one that parses. dnsmasq exits on a
# bad config file, and a resolver that is down is a job network with no DNS
# at all - which fails every run rather than the one that caused it.
dnsmasq --test --conf-file="$out" >/dev/null 2>&1 \
|| die "rendered config failed dnsmasq --test; leaving the existing one in place"
printf '%s\n' "$out"
}
# --- commands ---------------------------------------------------------------
cmd=render
args=()
while [ $# -gt 0 ]; do
case "$1" in
render|check|show) cmd="$1" ;;
--config)
[ $# -ge 2 ] && [ -n "${2:-}" ] || die "--config needs a path"
CONFIG="$2"; shift ;;
-h|--help) usage ;;
*) args+=("$1") ;;
esac
shift
done
[ ${#args[@]} -eq 0 ] || usage
read_config "$CONFIG"
# `show` only reads the already-rendered file, so it stays useful for
# diagnosis on a host whose configuration is exactly what is being diagnosed.
[ "$cmd" = show ] || preflight
case "$cmd" in
show)
[ -f "${CFG[out]}" ] || die "nothing installed at ${CFG[out]}"
cat "${CFG[out]}"
;;
check)
new=$(render); trap 'rm -f "$new"' EXIT
if [ -f "${CFG[out]}" ] && cmp -s "$new" "${CFG[out]}"; then
echo "up to date: $(( $(wc -l < "${CFG[out]}") - 1 )) names"
else
echo "would change:"
diff -u "${CFG[out]:-/dev/null}" "$new" || true
exit 1
fi
;;
render)
new=$(render); trap 'rm -f "$new"' EXIT
# Compare before writing: reloading on every timer tick drops the cache
# for no reason and makes the log useless for spotting when the
# allowlist actually changed.
if [ -f "${CFG[out]}" ] && cmp -s "$new" "${CFG[out]}"; then
exit 0
fi
install -m 0644 "$new" "${CFG[out]}"
${CFG[reload]}
echo "allowlist updated: $(( $(wc -l < "${CFG[out]}") - 1 )) names"
;;
esac