#!/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}"
# One or more, space-separated. The documented "a job network per runner" tier
# needs the same rules on each bridge, and the base set is shared either way.
#
# EMPTY IS ALLOWED, once pools exist. `bridge` names the shared network from
# before they did, and a host whose pools have all been given networks of their
# own has nothing left on it. Requiring it there meant the only way to retire
# that bridge was to leave a value naming an interface nothing uses, so the
# rules stayed and verify kept reporting it. Whether any bridge is known at all
# is checked below, once the pools file has had its say.
read -r -a BRIDGES <<< "$BRIDGE"

# Deferred until the pools file has been read, since either source may supply
# them and neither is required to on its own.
require_a_bridge() {
    [ "${#BRIDGES[@]}" -gt 0 ] && return 0
    die "no bridges to write rules for.

$CONFIG has no 'bridge', and $POOLS declares no pool. One of them has to name
an interface or there is nothing to apply rules to:

  sudo dejima-ci-allowlist pools add <owner/repo | owner> [project-dir]

or set 'bridge' in $CONFIG to the shared network's interface."
}

# 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.

# 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'\x00'
pkt = struct.pack('>HHHHHH', 0x4a4a, 0x0100, 1, 0, 0, 0) + q + struct.pack('>HH', 1, 1)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(3)
try:
    s.sendto(pkt, (sys.argv[1], 53)); s.recvfrom(512)
except OSError:
    sys.exit(1)
PROBE
fi

# hash:net, and created before any rule references it: an iptables rule naming
# a set that does not exist fails to load, and a half-loaded ruleset is an
# open one.
ipset create "$SET" hash:net -exist

# --- one set per project, plus one every project matches ---------------------
#
# The set holds addresses and nothing else - no memory of which name put one
# there - so a single shared set is a union at the address layer, not just at
# the name layer. A project that cannot resolve another's name could still
# connect to the address that name resolved to, and declarations are public.
# That is why scoping the resolver alone would have bought nothing.
#
# Each runner pool gets a set and a bridge of its own, holding the union of
# every declaration that pool could be made to run. Its jobs match that set and
# the base set, and nothing else.
POOLS=${POOLS:-$(cfg_get pools_file)}
: "${POOLS:=/etc/dejima-ci/pools}"

ipset create "${SET}-base" hash:net -exist

# The bridge name is derived, not configured, because it has to agree with what
# created the network. 15 characters is the kernel's limit on an interface name,
# which is why an id is a number rather than the scope it stands for.
# From the configured bridge when there is one, so an operator who named theirs
# something else keeps it. Otherwise the default, since with no shared bridge
# there is nothing to take the prefix from.
BRIDGE_PREFIX="${BRIDGES[0]:-}"; BRIDGE_PREFIX="${BRIDGE_PREFIX%%[0-9]*}"
: "${BRIDGE_PREFIX:=dejima-ci}"

declare -A BRIDGE_SET=()
if [ -r "$POOLS" ]; then
    while read -r scope id extra || [ -n "$scope" ]; do
        scope="${scope%%#*}"
        [ -n "$scope" ] && [ -n "${id:-}" ] || continue
        ipset create "${SET}-${id}" hash:net -exist
        BRIDGE_SET["${BRIDGE_PREFIX}${id}"]="${SET}-${id}"
        # Each pool's bridge takes rules whether or not it was named in the
        # config's bridge list, which otherwise only knows about the first one.
        case " ${BRIDGES[*]} " in
            *" ${BRIDGE_PREFIX}${id} "*) ;;
            *) BRIDGES+=("${BRIDGE_PREFIX}${id}") ;;
        esac
    done < "$POOLS"
fi

require_a_bridge

# Every bridge, from either source. It used to run before the pools file was
# read, so a pool whose network had never been created got rules naming an
# interface that was not there - which iptables accepts, and which leaves that
# pool's jobs on a bridge with no rules at all.
for b in ${BRIDGES[@]+"${BRIDGES[@]}"}; do
    ip link show "$b" >/dev/null 2>&1 \
        || die "no interface named $b - create its network first.

A pool's network is made by 'dejima-ci-allowlist pools add', and the shared
one by install.sh. If $b belongs to a pool that no longer exists, take it out
of $POOLS; if it is the shared network you have retired, clear 'bridge' in
$CONFIG."
done

# --- job egress: DOCKER-USER is the chain Docker jumps to first from FORWARD,
# --- and the only one whose contents Docker will not rewrite underneath you.
# Own a chain per table rather than appending into shared ones. This script
# runs on every Docker start (see dejima-ci-firewall.service), and appending
# to INPUT meant a fresh copy of every rule each time - a chain that grows
# without bound, and a listing nobody can read to check what is in force.
# Flushing INPUT instead is not an option: it belongs to the host.
#
# DOCKER-USER gets the same treatment for a different reason: Docker owns it,
# but so might another tool on the same box, and flushing it wholesale would
# take their rules with ours.
for ipt in iptables ip6tables; do
    # DOCKER-USER is Docker's, and in the v6 table it exists only when Docker
    # manages ip6tables. Creating it when absent keeps the jump below from
    # aborting the script on a host where it is not there.
    "$ipt" -N DOCKER-USER 2>/dev/null || true
    for chain in DEJIMA-CI-FWD DEJIMA-CI-IN; do
        "$ipt" -N "$chain" 2>/dev/null || true
        "$ipt" -F "$chain"
    done
    # -C then -I: idempotent, and inserted at the head so these are evaluated
    # before whatever else the host allows.
    "$ipt" -C DOCKER-USER -j DEJIMA-CI-FWD 2>/dev/null \
        || "$ipt" -I DOCKER-USER -j DEJIMA-CI-FWD
    "$ipt" -C INPUT -j DEJIMA-CI-IN 2>/dev/null \
        || "$ipt" -I INPUT -j DEJIMA-CI-IN
done

# Return traffic for connections that were allowed on their way out.
# Every rule below is applied per bridge. The chain flushes above happen
# once, outside this loop - flushing per bridge would delete the rules
# added for the previous one.
for b in "${BRIDGES[@]}"; do
    iptables -A DEJIMA-CI-FWD -i "$b" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

    # The allowlist proper. Port-restricted: a name being allowed is not a reason
    # for every port on it to be reachable.
    #
    # Two sets per bridge: the one this bridge's project declared, and the base
    # every project shares. A bridge with no entry in the projects file falls
    # back to the shared set, which is the pre-existing behaviour.
    # Base only where a bridge belongs to no pool. There is no shared set of
    # project names any more, so a bridge nothing claims reaches the names every
    # pool shares and nothing else. A job there fails rather than reaching too
    # far, which is the direction to be wrong in.
    for set_for_bridge in ${BRIDGE_SET[$b]:+"${BRIDGE_SET[$b]}"} "${SET}-base"; do
        iptables -A DEJIMA-CI-FWD -i "$b" -p tcp -m set --match-set "$set_for_bridge" dst \
            -m multiport --dports 80,443 -j ACCEPT
    done

    # Uncomment for git-over-ssh to a forge. Prefer https where the forge offers
    # it, so that this stays closed.
    # iptables -A DEJIMA-CI-FWD -i "$b" -p tcp --dport 22 -m set --match-set "$SET" dst -j ACCEPT

    # REJECT rather than DROP: a job that cannot reach something should fail
    # immediately and say so, not hang until a timeout and read as a slow network.
    iptables -A DEJIMA-CI-FWD -i "$b" -j REJECT --reject-with icmp-port-unreachable

    # --- the resolver: addressed to this host, so it never reaches DOCKER-USER.
    iptables -A DEJIMA-CI-IN -i "$b" -p udp --dport 53 -j ACCEPT
    iptables -A DEJIMA-CI-IN -i "$b" -p tcp --dport 53 -j ACCEPT

    # Everything else aimed at this host from the job network - the SSH daemon,
    # act_runner's cache server if you left it on, anything else listening - is
    # not part of running a job.
    iptables -A DEJIMA-CI-IN -i "$b" -j REJECT --reject-with icmp-port-unreachable

    # --- IPv6: blocked outright, because none of the above has an IPv6 twin.
    # An address family with no allowlist is not a smaller hole than the one this
    # script just closed; on a host with IPv6 egress it is the same hole.
    ip6tables -A DEJIMA-CI-FWD -i "$b" -j REJECT --reject-with icmp6-port-unreachable
    ip6tables -A DEJIMA-CI-IN -i "$b" -j REJECT --reject-with icmp6-port-unreachable
done

# These are not persistent. Reapply on boot, and after anything that reloads
# the firewall - Docker recreates its own chains on daemon restart, and a
# DOCKER-USER that came back empty is a runner with no egress policy at all.
