#!/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...]

  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

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; }

# --- 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
        # -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 reloads the resolver, e.g. systemctl reload dnsmasq"
    [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]}"
    fi

    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"
    # 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.
    domains=$(printf '%s\n' "$raw" | sed 's/#.*//' \
        | 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

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"
    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._-]*$ ]]
}

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"
}

# --- commands ---------------------------------------------------------------
cmd=render
args=()
repos_args=()
while [ $# -gt 0 ]; do
    case "$1" in
        render|check|show|repos) 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 ]; 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) ;; *) preflight ;; esac

case "$cmd" in
    repos)
        [ -n "${CFG[repos_file]}" ] || die "no repos_file in $CONFIG"
        cmd_repos "${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: $(( $(wc -l < "${CFG[out]}") - 1 )) 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]}: $(( $(wc -l < "$new") - 1 )) 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
        install -m 0644 "$new" "${CFG[out]}"
        ${CFG[reload]}
        echo "allowlist updated: $(( $(wc -l < "${CFG[out]}") - 1 )) names"
        ;;
esac
