#!/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]
       $PROG repos [list|add|remove] [owner/repo...]
       $PROG pools [list|add|remove] [owner/repo | owner] [project-dir]
       $PROG verify

  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
  repos    list, add to, or remove from the repositories trusted to widen this
           allowlist. Adding one used to mean re-running install.sh and
           retyping every entry already there
  verify   check that the host agrees with the pools file: every pool has its
           network, its rules name its own set and nothing else, and its
           runners are on its network. Exit 1 if they disagree, 2 if something
           could not be checked
  pools    declare the runner pools this host has, by what each is registered
           to. Each gets a network and a firewall set holding the union of what
           that pool could be made to run, and nothing else

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; }
say() { printf '%s\n' "$*" >&2; }

# CFG holds every known key, so an unset one is empty rather than absent.
# Names, not lines. Each name emits a server= and an ipset= line now, so
# counting lines and subtracting the header reported double and would have gone
# on reporting double quietly.
name_count() { grep -c '^server=/' "$1" 2>/dev/null || true; }

cfg_or_default() { [ -n "${CFG[$1]:-}" ] && printf '%s\n' "${CFG[$1]}" || printf '%s\n' "$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"
    [pools_file]="/etc/dejima-ci/pools"
    [base_file]="/etc/dejima-ci/base-domains"
    [upstream]=""
    [out]="/etc/dnsmasq.d/ci-allow.conf"
    [ref]="main"
    [reload]="systemctl restart 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
        # -e cannot tell "absent" from "I am not allowed to look". The config
        # lives in a 0750 root-owned directory, so every unprivileged run lands
        # here - and being told to run install.sh, when install.sh has already
        # run and the only thing missing is sudo, sends someone to redo work
        # that is already done.
        local dir; dir=$(dirname "$file")
        if [ -d "$dir" ] && [ ! -x "$dir" ]; then
            die "cannot tell whether $file exists: $dir is not searchable by $(id -un).

It is mode $(stat -c %a "$dir" 2>/dev/null || echo '?') and owned by $(stat -c %U "$dir" 2>/dev/null || echo '?'), so only that owner can look
inside it. This says nothing about whether the configuration is there.

Run the same command with sudo. This writes the resolver's configuration and
reloads it, so it needs root regardless of what it can read."
        fi
        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 makes the resolver read the new allowlist, e.g. systemctl restart dnsmasq - not reload, which is a SIGHUP and does not re-read config files"
    [token_file]="a file holding a read-only forge API token, mode 0600 - from the forge's Settings -> Applications page, NOT a runner registration token"
    [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 `sudo ./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
    # Counted on the way past, to stderr, because "my project declared domains
    # and got none of them" had no diagnostic anywhere. A repository absent
    # from repos_file is never fetched, so it produced no line, no error and no
    # trace - the operator had to already know the list existed to suspect it.
    # Now every consulted repository says what it gave, and the one that is
    # missing is missing from a list rather than from the operator's memory.
    case "$http" in
        200) say "  $repo: $(sed 's/#.*//' "$body" | grep -c '[^[:space:]]' || true) name(s) declared"
             cat "$body" ;;
        404) say "  $repo: no .dejima/allowed-domains on ${CFG[ref]}" ;;
        # 401 and 403 are the two an operator will actually meet, and they mean
        # different things: rejected outright, against accepted but not allowed
        # to read this. "unexpected HTTP 401" named neither, and left the one
        # credential this program holds as the last place anyone would look.
        401) rm -f "$body"
             die "the forge rejected the token (HTTP 401) reading $repo.

The token in ${CFG[token_file]} is present and not empty - an empty one is
refused before this point - so it is wrong rather than missing. Mistyped or
truncated when pasted, revoked since, expired, or carrying a stray space or
carriage return picked up on the way in.

Check first that it is an API token at all. This wants one from the forge's
Settings -> Applications page, with read access to repository contents. A
RUNNER REGISTRATION token - the one from Actions -> Runners, which registers a
runner and is what dejima-ci-runners asks for - is a different string that the
API rejects exactly like this. Swapping the two is easy and looks like nothing
else.

Reproduce it by hand with the request this just made:

  curl -sS -o /dev/null -w '%{http_code}\\n' \\
    -H \"Authorization: token \$(cat ${CFG[token_file]})\" \\
    '${CFG[forge_api]}/repos/$repo/raw/.dejima/allowed-domains?ref=${CFG[ref]}'

200 or 404 means the token is good. Re-run install.sh to replace it." ;;
        403) rm -f "$body"
             die "the forge accepted the token but refused the read (HTTP 403) for $repo.

The token in ${CFG[token_file]} authenticates, so this is permission rather
than identity. Either it lacks the scope to read repository contents - Gitea
tokens are scoped, and one created with none selected authenticates and can
read nothing - or the account behind it cannot see $repo.

Read access to repository contents is all this needs, and all it should have:
it only ever GETs one file per repository." ;;
        # Not followed. curl would follow this happily, and the whole premise
        # of this program is that the allowlist comes from a named branch of a
        # named repository - so quietly reading whatever the forge points at
        # instead is the one thing it must not do. A rename is also the most
        # likely cause, and saying so beats reporting a number.
        30[1278]) rm -f "$body"
             die "the forge redirected (HTTP $http) for $repo.

That repository has been renamed or moved to another owner, and this does not
follow redirects: the allowlist is read from a repository you named, and
following one would mean reading whichever repository the forge points at now.

Update the entry in ${CFG[repos_file]} to its current owner/repo and run this
again. Nothing has been changed; the previous allowlist is still in place." ;;
        *)   rm -f "$body"; die "unexpected HTTP $http for $repo" ;;
    esac
    rm -f "$body"
}

collect() {
    local token repo
    token=$(< "${CFG[token_file]}")
    # Trimmed, because the failure it prevents is invisible. Command
    # substitution strips trailing newlines and nothing else, so a token file
    # written with a trailing space, or saved by an editor that left a carriage
    # return, produces an Authorization header the forge rejects - and the file
    # looks correct in every way a person would check it, including `cat`.
    token="${token//$'\r'/}"
    token="${token#"${token%%[![:space:]]*}"}"
    token="${token%"${token##*[![:space:]]}"}"
    [ -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.
    # Comment-stripped, like the repos file one loop below and like install.sh
    # itself when it reads this back. It used to be cat'd raw, so a `#` line in
    # base-domains reached valid_domain and killed the run with "refusing a
    # malformed entry: #namesallowedregardless..." - a message that names no
    # file, and sends an operator to /etc/dejima-ci/config, which has a comment
    # at the top that install.sh put there and which was never the problem.
    if [ -r "${CFG[base_file]}" ]; then
        say "collecting:"
        say "  ${CFG[base_file]}: $(sed 's/#.*//' "${CFG[base_file]}" | grep -c . || true) name(s) always allowed"
        sed 's/#.*//' "${CFG[base_file]}" | sed "s|^|$(base_set)\t|"
    fi

    while read -r repo || [ -n "$repo" ]; do
        repo="${repo%%#*}"; repo="${repo//[[:space:]]/}"
        [ -n "$repo" ] || continue
        # Tagged with the set the names belong in, because which project asked
        # for a name is the whole of what makes it scopable. Collection used to
        # return bare names and the caller had no way to tell them apart again.
        # A repository that declares names and has no set of its own is
        # recorded rather than rendered. Recorded, not died on here: a die
        # inside a command substitution ends only the subshell, and the caller
        # would print its own vaguer message on top of this one.
        # Status propagated explicitly. Capturing into a variable put
        # fetch_repo's exit code where nothing looked at it, so a forge that
        # rejected the token became a repository that happened to declare
        # nothing, and the render carried on and installed a shorter allowlist.
        # It used to be the last command of a pipeline, where pipefail carried
        # the failure out for free.
        declared=$(fetch_repo "$repo" "$token") || return 1
        [ -n "$declared" ] || continue

        # No pool can run it, so its names have nowhere to go. Almost always a
        # repository added to the trusted list and never given a runner, which
        # is a job that would queue forever rather than a security decision -
        # but rendering its names into nothing would hide that.
        sets=$(sets_for_repo "$repo")
        if [ -z "$sets" ]; then
            printf '%s\n' "$repo" >> "$UNSCOPED"
            continue
        fi
        while IFS= read -r one_set; do
            [ -n "$one_set" ] || continue
            printf '%s\n' "$declared" | sed "s|^|$one_set\t|"
        done <<< "$sets"
    done < "${CFG[repos_file]}"
}

# --- which runner pools a repository's names have to reach ------------------
#
# THE SET BELONGS TO A POOL, not to a project, and it holds the union of every
# declaration that pool could be made to run. That is the only union that is
# safe: a pool takes jobs from the repositories it is registered for, so a
# hostile branch in one of them can already put a job on it. Adding that
# repository's names to that pool's set gives away nothing the branch did not
# already have, and leaving out every other repository's is the whole point.
#
# Labels cannot do this. A branch chooses its own runs-on, so a label is a
# request rather than a boundary. What a runner may take is decided by what it
# is registered to, which a branch cannot change.
#
# So the scoping an operator gets is the scoping they registered:
#
#   a pool registered to one repository   its declaration, and nothing else
#   a pool registered to an owner         the union across that owner's repos
#
# Repository scoping is how a pool gets a minimal set. It is the recommendation
# rather than the mechanism, and an owner-scoped pool is not a misconfiguration:
# it is a pool that genuinely can run any of those repositories' jobs, holding
# exactly what that implies.
# Fields: scope, id, and optionally the runner project directory. The directory
# is what lets dejima-ci-runners be told a pool rather than a path and a network,
# which is the difference between one wrong word and a pool quietly running on
# another pool's set.
pool_dir() {  # pool_dir scope -> the project directory, or empty
    [ -r "${CFG[pools_file]}" ] || return 0
    sed 's/#.*//' "${CFG[pools_file]}" | awk -v r="$1" '$1 == r { print $3; exit }'
}

pool_id() {  # pool_id scope -> id, or empty
    [ -r "${CFG[pools_file]}" ] || return 0
    sed 's/#.*//' "${CFG[pools_file]}" | awk -v r="$1" '$1 == r { print $2; exit }'
}

pool_ids_for_repo() {  # pool_ids_for_repo owner/repo -> ids, one per line
    local repo=$1 owner=${1%%/*}
    [ -r "${CFG[pools_file]}" ] || return 0
    sed 's/#.*//' "${CFG[pools_file]}" \
        | awk -v r="$repo" -v o="$owner" 'NF >= 2 && ($1 == r || $1 == o) { print $2 }'
}

sets_for_repo() {  # sets_for_repo owner/repo -> set names, one per line
    local id
    while IFS= read -r id; do
        [ -n "$id" ] || continue
        printf '%s-%s\n' "${CFG[ipset]}" "$id"
    done < <(pool_ids_for_repo "$1")
}

# Base names are reachable from every project, so they get a set of their own
# rather than sharing the one unassigned projects use - otherwise an assigned
# project matching "base" would be matching every unassigned project too, and
# the scoping would be for nothing.
base_set() { printf '%s-base\n' "${CFG[ipset]}"; }

render() {
    local raw pairs domains d out sets
    # 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.
    UNSCOPED=$(mktemp); export UNSCOPED
    raw=$(collect) || die "collection failed; leaving the existing allowlist in place"

    if [ -s "$UNSCOPED" ]; then
        local list; list=$(sort -u "$UNSCOPED" | sed 's/^/  /'); rm -f "$UNSCOPED"
        die "these repositories declare names, and no runner pool can run them:

$list

Nothing was installed; the allowlist already in place is untouched.

A pool is registered either to one repository or to an owner, and its set holds
what it could be made to run. Declare the pools you have:

  sudo $PROG pools add owner/repo   # a pool registered to that repository
  sudo $PROG pools add owner        # a pool registered to that owner

If a repository above has no runner at all, its jobs are queueing rather than
running, and the fix is a pool rather than an allowlist. If it should not be
trusted to widen anything:

  sudo $PROG repos remove owner/repo"
    fi
    rm -f "$UNSCOPED"
    # COMMENTS STRIPPED HERE, over everything, rather than at each source. The
    # first attempt at this stripped base-domains only, and the very next run
    # died on a `#` from a repository's own declaration - the same bug one
    # layer over, because a fetched body was cat'd raw too. There is one place
    # every name arrives, and this is it. A domain cannot contain `#`, so
    # nothing legitimate is lost.
    # Each line arrives as SET<tab>name. Blanks are squeezed out of the name
    # only: the tab is the structure, and tr -d '[:blank:]' across the line
    # would eat it and glue the two together.
    pairs=$(printf '%s\n' "$raw" | sed 's/#.*//' | tr -d '\r' \
        | awk -F'\t' 'NF == 2 { gsub(/[[:blank:]]/, "", $2); if ($2 != "") print $1 "\t" $2 }' \
        | sort -u || true)
    domains=$(printf '%s\n' "$pairs" | cut -f2 | sort -u)

    # 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

It came from ${CFG[base_file]} or from a .dejima/allowed-domains on one of the
repositories in ${CFG[repos_file]}. Nothing was installed; the existing
allowlist is untouched."
        printf 'server=/%s/%s\n' "$d" "${CFG[upstream]}" >> "$out"

        # THE SETS ARE WHERE THE SCOPING LIVES, not the server= line above.
        #
        # Every job can still resolve every allowlisted name: one dnsmasq has
        # one view, and giving each project its own would mean an instance per
        # project. That leak is names, and declarations are public in the
        # repositories they come from.
        #
        # What a job can CONNECT to is the set its bridge matches, and a name
        # only lands in the sets of the projects that asked for it. dnsmasq
        # takes several per name, so one lookup fills each project's set that
        # declared it and no others - which is what makes a shared resolver
        # safe here.
        sets=$(printf '%s\n' "$pairs" | awk -F'\t' -v n="$d" '$2 == n { print $1 }' \
               | sort -u | paste -sd, -)
        printf 'ipset=/%s/%s\n' "$d" "$sets" >> "$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"
}

# --- the trusted repository list --------------------------------------------
#
# Editing this needed install.sh, which asks "keep this list?" and treats no as
# "type the whole thing again". An operator adding one project retyped nine,
# from memory, in a prompt with no way back - and until they did, the new
# project's declaration was collected from nowhere and its jobs resolved
# nothing, with no error anywhere saying why. The list is a small file; adding
# to it should not be a re-run of the installer.
valid_repo_entry() {
    [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
}

# owner/repo, or a bare owner for a pool registered to an organisation or user.
valid_scope() {
    [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)?$ ]]
}

# The bridge names are the configured one with its trailing digits replaced, so
# there is one source of truth for the prefix and the existing bridge keeps
# whatever it was called.
bridge_prefix() { local b="${CFG[bridge]:-dejima-ci0}"; printf '%s\n' "${b%%[0-9]*}"; }

repos_read() {  # the entries, comments and blanks removed
    [ -r "${CFG[repos_file]}" ] || return 0
    sed 's/#.*//' "${CFG[repos_file]}" | tr -d '[:blank:]' | grep -v '^$' || true
}

repos_write() {  # repos_write <<< LIST
    local tmp; tmp=$(mktemp)
    sort -u > "$tmp"
    # The mode install.sh uses. This file decides whose main branch may widen
    # the allowlist, so it is not world-writable by accident here either.
    chmod 0640 "$tmp"
    mv "$tmp" "${CFG[repos_file]}"
}

cmd_repos() {  # cmd_repos [list|add|remove] [owner/repo...]
    local action=${1:-list}; shift || true
    local current; current=$(repos_read)

    case "$action" in
        list)
            [ -n "$current" ] || { say "no repositories trusted - nothing can widen this allowlist"; return 0; }
            printf '%s\n' "$current"
            return 0 ;;
        add|remove) ;;
        *) die "unknown repos action: $action (list, add, remove)" ;;
    esac

    [ "$#" -gt 0 ] || die "$action needs at least one owner/repo"
    local r changed=0 next="$current"
    for r in "$@"; do
        valid_repo_entry "$r" || die "not an owner/repo: $r (e.g. acme/widget)"
        if [ "$action" = add ]; then
            if printf '%s\n' "$next" | grep -qxF "$r"; then
                say "  already trusted: $r"
            else
                next=$(printf '%s\n%s\n' "$next" "$r"); changed=1
                say "  trusted: $r"
            fi
        else
            if printf '%s\n' "$next" | grep -qxF "$r"; then
                next=$(printf '%s\n' "$next" | grep -vxF "$r" || true); changed=1
                say "  no longer trusted: $r"
            else
                say "  was not trusted: $r"
            fi
        fi
    done

    [ "$changed" = 1 ] || { say "nothing changed"; return 0; }
    printf '%s\n' "$next" | grep -v '^$' | repos_write
    say ""
    say "Nothing is collected until the allowlist is rebuilt:"
    say "  sudo $PROG render"
}

cmd_pools() {  # cmd_pools [list|add|remove] [scope]
    local action=${1:-list}; shift || true
    local f="${CFG[pools_file]}"

    case "$action" in
        list)
            if [ -s "$f" ]; then
                printf '%-24s %-12s %-14s %s\n' SCOPE NETWORK SET 'RUNNER PROJECT'
                sed 's/#.*//' "$f" | awk -v s="${CFG[ipset]}" \
                    'NF >= 2 { printf "%-24s %-12s %-14s %s\n", $1, "dejima-ci-" $2, s "-" $2, ($3 == "" ? "(not recorded)" : $3) }'
            else
                say "no pools declared."
            fi
            say ""
            say "A scope is what a pool is REGISTERED to, which is the only thing a"
            say "branch cannot change. owner/repo is one repository; a bare owner is"
            say "every repository of theirs, which is what an organisation or user"
            say "scoped runner can run."
            return 0 ;;
        add|remove) ;;
        *) die "unknown pools action: $action (list, add, remove)" ;;
    esac

    [ "$#" -ge 1 ] || die "$action needs a scope: owner/repo, or a bare owner"
    local scope="$1" dir="${2:-}"
    valid_scope "$scope" || die "not a scope: $scope

A scope is owner/repo for a pool registered to one repository, or a bare owner
for one registered to an organisation or a user."

    local current; current=$([ -r "$f" ] && sed 's/#.*//' "$f" | grep -v '^[[:space:]]*$' || true)

    if [ "$action" = remove ]; then
        printf '%s\n' "$current" | awk -v r="$scope" '$1 != r' | grep -v '^$' > "$f.new" || true
        chmod 0640 "$f.new"; mv "$f.new" "$f"
        say "$scope removed. Its names go nowhere until another pool covers them."
        say "Re-run: sudo $PROG render, then sudo dejima-ci-firewall"
        return 0
    fi

    local existing; existing=$(printf '%s\n' "$current" | awk -v r="$scope" '$1 == r { print $2; exit }')
    if [ -n "$existing" ]; then
        # Re-running with a directory records it, rather than reporting the id
        # and leaving the operator to edit the file. Attaching the project is
        # the step that lets the pool be named instead of described.
        if [ -n "$dir" ]; then
            printf '%s\n' "$current" | awk -v r="$scope" '$1 != r' | grep -v '^$' > "$f.new" || true
            printf '%s %s %s\n' "$scope" "$existing" "$dir" >> "$f.new"
            chmod 0640 "$f.new"; mv "$f.new" "$f"
            say "$scope: runner project recorded as $dir"
            say ""
            say "Now it can be moved onto its own network by name:"
            say "  sudo dejima-ci-runners --pool $scope"
            return 0
        fi
        say "$scope already has id $existing (${CFG[ipset]}-$existing)"
        [ -n "$(pool_dir "$scope")" ] || {
            say ""
            say "Its runner project is not recorded, so it cannot be moved by name."
            say "  sudo $PROG pools add $scope /path/to/that/project"
        }
        return 0
    fi

    # Never reused, even when one is freed. An id names a set and a bridge that
    # may still hold addresses and still carry rules, and handing a freed one to
    # a different pool would give it whatever the last occupant left behind.
    local next; next=$(printf '%s\n' "$current" | awk 'NF >= 2 { print $2 }' | sort -n | tail -1)
    next=$(( ${next:-0} + 1 ))

    printf '%s\n' "$current" | grep -v '^$' > "$f.new" || true
    printf '%s %s %s\n' "$scope" "$next" "$dir" >> "$f.new"
    chmod 0640 "$f.new"; mv "$f.new" "$f"

    local br="$(bridge_prefix)$next" net="dejima-ci-$next"
    say "$scope -> ${CFG[ipset]}-$next, on bridge $br"
    say ""
    say "That set will hold the union of every declaration this pool could be"
    say "made to run, which for a bare owner is all of theirs. Register the pool"
    say "to one repository if you want a smaller set."
    say ""

    # Created here rather than printed for the operator to paste. The bridge
    # name has to match what the firewall will write rules for, and it is the
    # one value in this whole procedure that is neither guessable nor checked
    # anywhere else: a typo in it produces a network that works, a set that
    # fills, and a firewall rule for an interface that does not exist.
    if ! command -v docker >/dev/null 2>&1; then
        say "docker is not on this host, so the network was not created. On the"
        say "host that runs the runners:"
        say "  docker network create --driver bridge \\"
        say "    -o com.docker.network.bridge.name=$br $net"
    elif docker network inspect "$net" >/dev/null 2>&1; then
        say "network $net already exists, left alone."
    elif docker network create --driver bridge \
            -o "com.docker.network.bridge.name=$br" "$net" >/dev/null 2>&1; then
        say "created network $net on bridge $br"
    else
        say "COULD NOT CREATE network $net. Create it before rendering:"
        say "  docker network create --driver bridge \\"
        say "    -o com.docker.network.bridge.name=$br $net"
    fi

    say ""
    say "Then, to put it in force:"
    say "  sudo $PROG render"
    say "  sudo dejima-ci-firewall"
    say ""
    say "And point this pool's runners at that network. Name the pool rather"
    say "than the network and the path: both are in this file, and a command"
    say "carrying them is a command somebody copies for the second pool while"
    say "changing only one of them."
    if [ -n "$dir" ]; then
        say "  sudo dejima-ci-runners --pool $scope"
    else
        say "  sudo $PROG pools add $scope /path/to/its/runner/project"
        say "  sudo dejima-ci-runners --pool $scope"
    fi
    say ""
    say "The pool has to be REGISTERED at this scope for any of it to mean"
    say "anything. A label is a request a branch makes; the registration is what"
    say "decides which jobs a runner may take."
}

# --- does the host agree with the file ---------------------------------------
#
# Four things have to line up for a pool to be scoped, and each is written by a
# different command at a different time: the pools file says which set and
# bridge are its, docker owns the network, iptables owns the rules, and the
# runner project says which network its jobs land on. Three of them agreeing is
# not scoping, and every way of getting it wrong looks like it worked - the
# runners register, the jobs pass, and the allowlist is somebody else's.
#
# NEVER PASSES WHAT IT COULD NOT CHECK. Missing docker, missing iptables or no
# privilege is INDETERMINATE, not a clean report. A verifier that reports
# success when it did not look is worse than no verifier, because it is the one
# that gets believed.
V_FAIL=0
V_UNKNOWN=0
v_ok()      { printf '  ok        %s\n' "$*" >&2; }
v_fail()    { printf '  FAIL      %s\n' "$*" >&2; V_FAIL=$((V_FAIL + 1)); }
v_unknown() { printf '  UNKNOWN   %s\n' "$*" >&2; V_UNKNOWN=$((V_UNKNOWN + 1)); }
# Neither a pass nor a failure: something to tidy, which should not make an
# otherwise correct host report a problem. A verifier that cries about leftovers
# is one whose real findings get skimmed past.
v_note()    { printf '  note      %s\n' "$*" >&2; }

# The docker network sitting on a bridge, since the two are named independently
# and the pre-pools one is not called dejima-ci-<id> at all.
network_on_bridge() {  # network_on_bridge iface -> network name, or empty
    command -v docker >/dev/null 2>&1 || return 0
    local n
    while IFS= read -r n; do
        [ -n "$n" ] || continue
        [ "$(docker network inspect "$n" \
             --format '{{index .Options "com.docker.network.bridge.name"}}' 2>/dev/null)" = "$1" ] \
            && { printf '%s\n' "$n"; return 0; }
    done < <(docker network ls --format '{{.Name}}' 2>/dev/null || true)
    return 0
}

attached_count() {  # attached_count network -> how many containers, or empty
    command -v docker >/dev/null 2>&1 || return 0
    docker network inspect "$1" --format '{{len .Containers}}' 2>/dev/null || true
}

verify_network() {  # verify_network id bridge
    local id=$1 br=$2 net="dejima-ci-$id" actual
    if ! command -v docker >/dev/null 2>&1; then
        v_unknown "network $net: docker is not on this host"; return
    fi
    if ! actual=$(docker network inspect "$net" \
                  --format '{{index .Options "com.docker.network.bridge.name"}}' 2>/dev/null); then
        v_fail "network $net does not exist, so its bridge carries no jobs"
        return
    fi
    if [ "$actual" = "$br" ]; then
        v_ok "network $net is on $br"
    else
        # The rules name a bridge. A network on a different one means the rules
        # are written for an interface nothing arrives on, which fails open:
        # the jobs are on an interface with no rules at all.
        v_fail "network $net is on bridge '${actual:-<unset>}', but its rules name $br"
    fi
}

verify_rules() {  # verify_rules id bridge set
    local id=$1 br=$2 want=$3 base="${CFG[ipset]}-base" rules
    if ! command -v iptables >/dev/null 2>&1; then
        v_unknown "rules for $br: iptables is not on this host"; return
    fi
    if ! rules=$(iptables -S DEJIMA-CI-FWD 2>/dev/null); then
        v_unknown "rules for $br: cannot read DEJIMA-CI-FWD (try sudo, or run dejima-ci-firewall)"
        return
    fi
    # || true, because a bridge with no rules is a FINDING and grep answers
    # "nothing matched" with a non-zero status. Under pipefail that aborts the
    # whole command, so the one pool that needed reporting took every check
    # after it down too, silently, leaving a report that looked merely short.
    local named; named=$(printf '%s\n' "$rules" \
        | grep -- "-i $br " | grep -oE -- '--match-set [^ ]+' | awk '{print $2}' | sort -u || true)
    if [ -z "$named" ]; then
        v_fail "$br has no allowlist rule, so its jobs reach nothing (or everything, if another chain permits them)"
        return
    fi
    local unexpected; unexpected=$(printf '%s\n' "$named" | grep -vx -e "$want" -e "$base" || true)
    if [ -n "$unexpected" ]; then
        # The failure this whole design exists to prevent, and the one that is
        # invisible from every other angle.
        v_fail "$br matches $(printf '%s' "$unexpected" | paste -sd, -), which is another pool's"
        return
    fi
    printf '%s\n' "$named" | grep -qx "$want" \
        || { v_fail "$br does not match its own set $want"; return; }
    printf '%s\n' "$named" | grep -qx "$base" \
        || { v_fail "$br does not match $base, so shared names are unreachable"; return; }
    v_ok "$br matches $want and $base, and nothing else"
}

verify_project() {  # verify_project scope id dir
    local scope=$1 id=$2 dir=$3 want="dejima-ci-$id" actual
    if [ -z "$dir" ]; then
        v_unknown "$scope: no runner project recorded, so nothing says which network its jobs use"
        return
    fi
    if [ ! -f "$dir/config.yaml" ]; then
        v_fail "$scope: no runner project at $dir"
        return
    fi
    actual=$(sed -n 's|^ *network: *"\{0,1\}\([^"]*\)"\{0,1\} *$|\1|p' "$dir/config.yaml" | head -1)
    if [ "$actual" = "$want" ]; then
        v_ok "$scope runners are on $want"
    else
        v_fail "$scope runners are on '${actual:-<unset>}', not $want - they carry another pool's allowlist"
    fi
}

verify_set() {  # verify_set name
    if ! command -v ipset >/dev/null 2>&1; then
        v_unknown "set $1: ipset is not on this host"; return
    fi
    if ipset list -n 2>/dev/null | grep -qx "$1"; then
        v_ok "set $1 exists"
    else
        v_fail "set $1 does not exist, so the rules naming it cannot load"
    fi
}

# The address dnsmasq answers on, and the one every pool's runners are given as
# --dns. It belongs to the shared network's bridge, not to any pool's, so it is
# the one thing the shared network is still for after every pool has its own.
#
# Checked first because losing it takes DNS away from every pool at once, and
# the symptom is jobs failing in apt rather than anything naming a resolver.
verify_gateway() {
    local gw="${CFG[gateway]}"
    [ -n "$gw" ] || { v_unknown "no gateway in $CONFIG, so nothing says where the resolver listens"; return; }
    if ! command -v ip >/dev/null 2>&1; then
        v_unknown "resolver address $gw: 'ip' is not on this host"; return
    fi
    local on; on=$(ip -o -4 addr show 2>/dev/null | awk -v g="$gw" '$4 ~ "^" g "/" { print $2; exit }')
    if [ -n "$on" ]; then
        v_ok "resolver address $gw is on $on"
    else
        v_fail "resolver address $gw is on no interface, so dnsmasq cannot answer"
        say "              Every pool's runners are given --dns $gw, so this is DNS"
        say "              gone for all of them at once. It belongs to the shared"
        say "              network's bridge. Recreate that network:"
        say "                docker network create --driver bridge \\"
        say "                  -o com.docker.network.bridge.name=$(cfg_or_default bridge dejima-ci0) \\"
        say "                  --subnet $(cfg_or_default subnet '<subnet>') --gateway $gw \\"
        say "                  $(cfg_or_default network dejima-ci)"
        say "                sudo systemctl restart dnsmasq"
    fi
}

cmd_verify() {
    local f="${CFG[pools_file]}" any=0
    [ -s "$f" ] || die "no pools declared in $f - nothing to verify.
  sudo $PROG pools add <owner/repo | owner> [project-dir]"

    verify_gateway
    verify_set "${CFG[ipset]}-base"
    say ""

    while read -r scope id dir || [ -n "$scope" ]; do
        scope="${scope%%#*}"
        [ -n "$scope" ] && [ -n "${id:-}" ] || continue
        any=1
        say "$scope"
        verify_set "${CFG[ipset]}-$id"
        verify_network "$id" "$(bridge_prefix)$id"
        verify_rules "$id" "$(bridge_prefix)$id" "${CFG[ipset]}-$id"
        verify_project "$scope" "$id" "${dir:-}"
        say ""
    done < "$f"
    [ "$any" = 1 ] || die "no usable lines in $f"

    # THE DANGEROUS DIRECTION, checked first: a network that exists whose bridge
    # has no rules. Every rule in DEJIMA-CI-FWD names an interface, so a bridge
    # with none matches nothing, falls off the end of the chain and returns to
    # Docker's default, which accepts. Anything landing there has unrestricted
    # egress - a runner made without --pool, or a container left behind.
    #
    # Rules without a network are the harmless direction and are reported below.
    # This is the one that fails open, and it is invisible: the network works,
    # the jobs pass, and nothing is filtering them.
    if command -v docker >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1 \
       && iptables -S DEJIMA-CI-FWD >/dev/null 2>&1; then
        local ruled n nb
        ruled=$(iptables -S DEJIMA-CI-FWD 2>/dev/null \
                | grep -oE -- '-i [^ ]+' | awk '{print $2}' | sort -u || true)
        while IFS= read -r n; do
            [ -n "$n" ] || continue
            nb=$(docker network inspect "$n" \
                 --format '{{index .Options "com.docker.network.bridge.name"}}' 2>/dev/null || true)
            [ -n "$nb" ] || continue
            case "$nb" in "$(bridge_prefix)"*) ;; *) continue ;; esac
            printf '%s\n' "$ruled" | grep -qx "$nb" && continue
            say "unfiltered"
            v_fail "$n is on $nb, which has no rules: anything on it reaches anything."
            say "              Every rule names an interface, so a bridge with none"
            say "              falls through to Docker's default, which accepts."
            say "              If it is the shared network, put its bridge back:"
            say "                set 'bridge = $nb' in $CONFIG"
            say "                sudo dejima-ci-firewall"
            say ""
        done < <(docker network ls --format '{{.Name}}' 2>/dev/null || true)
    fi

    # A bridge with rules that no pool claims. Almost always a pool removed from
    # the file while its rules and its runners kept going, which is a set of
    # jobs nothing here describes any more.
    if command -v iptables >/dev/null 2>&1 && iptables -S DEJIMA-CI-FWD >/dev/null 2>&1; then
        local claimed stray
        claimed=$(sed 's/#.*//' "$f" | awk -v b="$(bridge_prefix)" 'NF >= 2 { print b $2 }' | sort -u)
        stray=$(iptables -S DEJIMA-CI-FWD 2>/dev/null \
            | grep -oE -- '-i [^ ]+' | awk '{print $2}' | sort -u \
            | grep -E "^$(bridge_prefix)[0-9]+$" \
            | grep -vxF -e "$claimed" || true) || true
        if [ -n "$stray" ]; then
            # Not "unclaimed": the shared bridge lands here and is reported ok,
            # under a heading calling it a leftover. A heading that disagrees
            # with the line under it is read before the line is.
            say "bridges no pool claims"
            local configured=" $(cfg_or_default bridge '') "
            while IFS= read -r b; do
                [ -n "$b" ] || continue
                local net att what
                net=$(network_on_bridge "$b")
                att=$(attached_count "${net:-}")

                case "$configured" in
                    *" $b "*) what="the shared network from before pools existed" ;;
                    *)        what="a pool that was removed while its rules stayed" ;;
                esac

                # The shared network holds the resolver address every pool
                # depends on, so it is not retirable however empty it looks.
                # Advising `docker network rm` on it took DNS away from every
                # pool on a live host.
                local holds_resolver=0
                if [ -n "${CFG[gateway]}" ] && command -v ip >/dev/null 2>&1; then
                    [ "$(ip -o -4 addr show dev "$b" 2>/dev/null \
                         | awk -v g="${CFG[gateway]}" '$4 ~ "^" g "/" { print "y"; exit }')" = y ] \
                        && holds_resolver=1
                fi

                if [ "$holds_resolver" = 1 ]; then
                    # Expected, and not something to tidy. It holds the address
                    # every pool resolves against, and its rules are what
                    # contains anything that lands on it - a runner made
                    # without --pool goes here. Suggesting either half be
                    # removed was wrong twice over.
                    v_ok "$b is the shared network: it holds the resolver at ${CFG[gateway]}, and its rules contain anything that lands on it"
                elif [ "${att:-0}" -gt 0 ] 2>/dev/null; then
                    # Containers on a bridge no pool describes are jobs running
                    # under an allowlist nothing here decided. That is a finding.
                    v_fail "$b has ${att} container(s) and no pool claims it: $what."
                    say "              Those jobs reach the base names only, since its rules"
                    say "              name no pool's set. Move them onto their pool:"
                    say "                sudo dejima-ci-runners --pool <scope>"
                else
                    # Rules for a bridge nothing is on. Untidy rather than unsafe,
                    # so it does not fail the run.
                    v_note "$b carries rules but nothing is on it: $what."
                    say "              Nothing reaches anything through it. To retire it:"
                    [ -n "$net" ] && say "                docker network rm $net"
                    # Cleared, and said exactly. "Remove it from the bridge
                    # line" leaves an empty value, and the firewall used to
                    # refuse to start on one - a worse place to be than the
                    # leftover rules were.
                    say "                set 'bridge =' in $CONFIG, with nothing after it"
                    say "                sudo dejima-ci-firewall"
                    say "              An empty bridge is fine once every pool has a network"
                    say "              of its own: the pools supply the interfaces."
                fi
            done <<< "$stray"
            say ""
        fi
    fi

    if [ "$V_FAIL" -gt 0 ]; then
        say "$V_FAIL problem(s). Each one is a pool running with an allowlist that is"
        say "not the one its file describes, which looks like success from every"
        say "other angle: the runners register and the jobs pass."
        return 1
    fi
    if [ "$V_UNKNOWN" -gt 0 ]; then
        say "$V_UNKNOWN thing(s) could not be checked, so this is not a pass. Run it"
        say "on the runner host, with sudo."
        return 2
    fi
    say "Everything agrees: each pool has its network, its rules name its own set"
    say "and the base set, and its runners are on its network."
    return 0
}

# --- commands ---------------------------------------------------------------
cmd=render
args=()
repos_args=()
while [ $# -gt 0 ]; do
    case "$1" in
        render|check|show|repos|pools|verify) cmd="$1" ;;
        --config)
            [ $# -ge 2 ] && [ -n "${2:-}" ] || die "--config needs a path"
            CONFIG="$2"; shift ;;
        -h|--help) usage ;;
        *) args+=("$1") ;;
    esac
    shift
done
# `repos` takes operands; nothing else does. Consuming the rest of the line at
# the point `repos` is seen would have been simpler and wrong: it swallowed a
# following --config, so `repos add x --config /path` edited the file named by
# the default config rather than the one asked for, silently.
if [ "$cmd" = repos ] || [ "$cmd" = pools ]; then
    repos_args=("${args[@]}")
else
    [ ${#args[@]} -eq 0 ] || usage
fi

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.
# Neither show nor repos needs the forge, the token or the resolver: one reads
# an already-rendered file and the other edits a list of names. Requiring a
# working configuration to look at either would make them useless on the host
# whose configuration is the thing being diagnosed.
case "$cmd" in show|repos|pools|verify) ;; *) preflight ;; esac

case "$cmd" in
    repos)
        [ -n "${CFG[repos_file]}" ] || die "no repos_file in $CONFIG"
        cmd_repos "${repos_args[@]:-}"
        ;;
    verify)
        [ -n "${CFG[pools_file]}" ] || die "no pools_file in $CONFIG"
        cmd_verify
        ;;
    pools)
        [ -n "${CFG[pools_file]}" ] || die "no pools_file in $CONFIG"
        cmd_pools "${repos_args[@]:-}"
        ;;
    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: $(name_count "${CFG[out]}") names"
        elif [ -f "${CFG[out]}" ]; then
            echo "would change:"
            diff -u "${CFG[out]}" "$new" || true
            exit 1
        else
            # :- substitutes an EMPTY variable, not a missing file, so this
            # used to hand diff a path that is not there and print its
            # complaint - on a first install, where nothing is wrong at all.
            echo "would install ${CFG[out]}: $(name_count "$new") names"
            diff -u /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
        # REMOVING A NAME DID NOT REVOKE ANYTHING. dnsmasq puts the addresses
        # it answers with into the firewall's set, and nothing ever took them
        # out: after a re-render the name stopped resolving, and every address
        # it had ever resolved to stayed reachable until the host rebooted. A
        # job that had already looked the name up, or that reads the address
        # out of the declaration's history, connected to it as before.
        #
        # So a name being dropped means the set no longer describes the policy,
        # and the only fix ipset offers is to empty it: the set holds addresses
        # with no memory of which name put them there.
        removed=""
        if [ -f "${CFG[out]}" ]; then
            removed=$(comm -23 \
                <(sed -n 's|^server=/\([^/]*\)/.*|\1|p' "${CFG[out]}" | sort -u) \
                <(sed -n 's|^server=/\([^/]*\)/.*|\1|p' "$new"          | sort -u))
        fi

        install -m 0644 "$new" "${CFG[out]}"

        # A RESTART, NOT A SIGHUP. SIGHUP clears the cache and re-reads hosts
        # files, but not the config: a name added to the allowlist stayed
        # unresolvable until dnsmasq happened to restart, while show and check
        # both reported the file on disk as up to date.
        #
        # Order matters. Flush first, then restart: the restart empties
        # dnsmasq's cache, so the next query for a name that is still allowed
        # goes upstream and puts its address back. Restarting first would
        # leave a cached answer that repopulates nothing, and names that are
        # still on the list would be unreachable until their TTL ran out.
        #
        # Only on a removal. Flushing whenever anything changed would empty the
        # set every time a name was added, and a job mid-run would find a
        # destination it had already resolved refused until it looked it up
        # again. Established connections survive either way - the firewall
        # accepts ESTABLISHED,RELATED before it consults the set.
        if [ -n "$removed" ]; then
            # EVERY set, not the one named by the config key.
            #
            # That key is a prefix now: names live in a set per project plus a
            # base set, and the bare name holds nothing. Flushing it alone would
            # have emptied an unused set and reported a revocation that had not
            # happened - the same failure this flush was added to fix, wearing
            # the fix as a disguise.
            #
            # A set holds addresses with no memory of which name put them there,
            # so there is no way to flush only the project a removed name
            # belonged to. All of them, and they refill from the next lookup.
            set_prefix=$(cfg_or_default ipset ci-allow)
            if ! command -v ipset >/dev/null 2>&1; then
                say "WARNING: ipset is not installed here, so nothing was revoked."
                say "  The names are gone from the resolver, but any address already"
                say "  in the firewall's sets stays reachable."
            else
                flushed=0
                while IFS= read -r one; do
                    case "$one" in "$set_prefix"|"$set_prefix"-*) ;; *) continue ;; esac
                    ipset flush "$one" && flushed=$(( flushed + 1 )) \
                        || say "WARNING: could not flush $one"
                done < <(ipset list -n 2>/dev/null || true)
                if [ "$flushed" -gt 0 ]; then
                    say "flushed $flushed set(s): $(printf '%s\n' "$removed" | grep -c .) name(s) revoked"
                else
                    say "WARNING: no $set_prefix set exists here, so nothing was revoked."
                    say "  The names are gone from the resolver, but any address already"
                    say "  in the firewall's sets stays reachable. Run dejima-ci-firewall."
                fi
            fi
        fi

        ${CFG[reload]}
        echo "allowlist updated: $(name_count "${CFG[out]}") names"
        ;;
esac
