#!/usr/bin/env bash
# corecp-fleet-apply — the only privileged thing the fleet plane can cause.
#
# The panel runs as corecp-panel under ProtectSystem=strict: it cannot write
# /etc/apt, cannot run apt-get, and deliberately has no sudo rule that would let
# it. So a directive that has to touch this machine is written as a request file
# into /var/lib/corecp-panel/fleet/spool, and this script — started as root by
# corecp-fleet-apply.path — carries it out and writes the answer back.
#
# That makes the spool directory the whole privileged surface between a primary
# panel somewhere else and root on this machine. Which is why the `case` below
# is written out rather than derived: it is the fourth and last place the fleet
# vocabulary appears (the Go catalogue, the replica's dispatch table, the
# database CHECK, and here), and it is the one that is between a compromised
# primary and this server.
#
#   /usr/local/lib/corecp-panel/corecp-fleet-apply          drain the spool once
#   SPOOL=/tmp/spool /usr/local/lib/corecp-panel/corecp-fleet-apply
#
# Idempotent: a request already answered is skipped, and every action it takes
# is one that can be taken twice.
set -uo pipefail

SPOOL=${SPOOL:-/var/lib/corecp-panel/fleet/spool}
APT_AUTH=${APT_AUTH:-/etc/apt/auth.conf.d/corecp-fleet.conf}
APT_SOURCES=${APT_SOURCES:-/etc/apt/sources.list.d/corecp-fleet.sources}
APT_KEYRING=${APT_KEYRING:-/usr/share/keyrings/corecp-archive-keyring.gpg}
# The packages a managed panel installs from the shared build plane. Named
# rather than "everything from that repository": an entitled repository is a
# credential, not a licence to replace arbitrary packages on this machine.
PACKAGES=${PACKAGES:-corecp-panel corecp-agent}

# The journal, kept apart from the answer.
#
# The drain below captures a verb's output with `2>&1`, because the answer the
# primary gets is whatever the verb printed. Anything meant for the operator on
# THIS machine therefore cannot go to stderr — it would end up in the
# acknowledgement, which is one line by design. So the script's own stderr is
# duplicated here, before anything redirects it, and the running commentary goes
# there instead.
exec 3>&2
log() { echo "[corecp-fleet-apply] $*" >&2; }
journal() { cat "$1" >&3; }

# one_line refuses a value that could open a second line in a configuration file.
#
# Two of the four verbs template directive arguments into root-owned files that
# are read line by line — the apt sources stanza and the apt credential. A value
# carrying a newline is therefore not a value, it is extra configuration: a
# base_url of "https://real/repo\nTrusted: yes\n#" plants `Trusted: yes` in the
# sources file and turns off apt's own repository-signature check.
#
# The primary is the party this script exists to be careful about, so the check
# lives here rather than only at the panel that relays it. Refuse, never strip:
# a value silently edited to something the primary did not send is a credential
# nobody can debug.
one_line() { # one_line <what> <value>
    case "$2" in
        *[$'\n\r']*) echo "the $1 contains a line break; refusing to write it into a configuration file"; return 1 ;;
        "")            echo "the $1 is empty"; return 1 ;;
    esac
    return 0
}

# answer writes the result file the panel is waiting on, atomically.
answer() {
    local id=$1 ok=$2 detail=$3
    local out="$SPOOL/res-$id.json"
    python3 - "$out" "$id" "$ok" "$detail" <<'PY'
import json, os, sys
out, rid, ok, detail = sys.argv[1:5]
tmp = out + ".tmp"
with open(tmp, "w") as f:
    json.dump({"id": rid, "ok": ok == "1", "detail": detail}, f)
os.replace(tmp, out)
PY
}

# field reads one value out of the request without a JSON parser in bash.
field() {
    python3 - "$1" "$2" <<'PY'
import json, sys
req = json.load(open(sys.argv[1]))
key = sys.argv[2]
if key in ("id", "verb"):
    print(req.get(key, ""))
else:
    print((req.get("args") or {}).get(key, ""))
PY
}

# ---------------------------------------------------------------------------
# the four privileged verbs
# ---------------------------------------------------------------------------

# write_sources points apt at the entitled repository for one channel.
write_sources() {
    local channel=$1 base=$2
    [ -n "$base" ] || { echo "no entitled base URL is configured on this machine"; return 1; }
    one_line "entitled base URL" "$base" || return 1
    case "$base" in
        https://*|http://*) ;;
        *) echo "the entitled base URL is not an http(s) URL: $base"; return 1 ;;
    esac
    local tmp
    tmp=$(mktemp)
    cat >"$tmp" <<EOF
# Written by corecp-fleet-apply. The channel is assigned by this panel's
# primary; the credential is in $APT_AUTH.
Types: deb
URIs: $base
Suites: $channel
Components: main
Architectures: amd64
Signed-By: $APT_KEYRING
EOF
    install -m 0644 "$tmp" "$APT_SOURCES"
    rm -f "$tmp"
}

# probe_suite asks the repository for one channel's Release file and prints the
# HTTP status.
#
# The credential is handed to curl as a FILE and never as an argument. apt's
# auth.conf.d format is netrc's — machine, login, password — so curl reads the
# very file apt reads, and the password never appears in argv, in
# /proc/<pid>/cmdline, or in the execve record an audit daemon keeps forever.
#
# This function used to carry that sentence as a comment and then pass
# `--user "$login:$password"` anyway: it read the secret out of the file and put
# it straight back on a command line. Found by the security review of session
# r5-fleet-apply; it is the same rule as the fleet-wide argv sweep (#76/#83).
probe_suite() {
    local base=$1 channel=$2
    local args=(-sS -o /dev/null -w '%{http_code}' --max-time 20)
    [ -f "$APT_AUTH" ] && args+=(--netrc-file "$APT_AUTH")
    curl "${args[@]}" "$base/dists/$channel/InRelease" 2>/dev/null || echo 000
}

# current_base reads the URI back out of the sources file, so `update.set_channel`
# does not need the entitlement handed to it again.
current_base() {
    [ -f "$APT_SOURCES" ] || return 0
    awk '/^URIs:/ {print $2; exit}' "$APT_SOURCES"
}

current_channel() {
    [ -f "$APT_SOURCES" ] || return 0
    awk '/^Suites:/ {print $2; exit}' "$APT_SOURCES"
}

do_set_channel() {
    local channel=$1
    case "$channel" in
        edge|beta|stable|steady) ;;
        *) echo "unknown channel: $channel"; return 1 ;;
    esac
    local base
    base=$(current_base)
    write_sources "$channel" "$base" || return 1
    # Prove the suite exists before calling this a success. A channel that has
    # no published suite turns every later update into a fetch error and the
    # panel would have no way back, so the failure belongs here — while it is
    # still one directive that did not apply.
    #
    # 401 is the exception, and it is not a failure: the credential was minted
    # by the primary seconds ago and the build server pulls the list on its own
    # timer. Treating "not yet propagated" as "wrong channel" would fail every
    # first connect, which is exactly what the first live join did.
    local code
    code=$(probe_suite "$base" "$channel")
    case "$code" in
        200) echo "channel $channel from $base" ;;
        401|403)
            echo "channel $channel from $base — the entitlement has not reached the build server yet (it syncs on a timer); apt will start working within a minute" ;;
        404)
            echo "there is no $channel suite at $base"; return 1 ;;
        *)
            echo "could not reach $base ($code)"; return 1 ;;
    esac
}

# do_apply installs what the channel offers, behind the signature check.
#
# It runs no apt-get install of its own. `corectl fleet-update` does that, and
# it does it in the one order that makes the check a check: verify the signed
# release manifest for the version apt intends to install, download without
# installing, verify the bytes on disk against the signed length and SHA-512,
# and only then let dpkg near them. That is the same code path a hosting node
# runs (corectl/internal/core/update_verify.go), which is the whole point: until
# this called it, the machines running the fleet were the only CoreCP machines
# installing on apt's word alone. R38.
#
# corectl is not optional here. corecp-panel Depends on corecp-agent precisely
# so that this binary exists on every managed panel, and a missing verifier is
# refused rather than routed around: "there is no signature check available" is
# exactly the sentence that must never become a way past a signature check.
do_apply() {
    local version=${1:-} allow=${2:-} reason=${3:-} actor=${4:-}
    if ! command -v corectl >/dev/null 2>&1; then
        echo "corectl is not installed on this panel, so no signed manifest can be verified; refusing to install unverified packages (apt-get install corecp-agent)"
        return 1
    fi
    if ! apt-get update >/dev/null 2>&1; then
        echo "apt-get update failed"
        return 1
    fi
    local args=(fleet-update --packages "${PACKAGES// /,}" --actor "${actor:-fleet}")
    [ -n "$version" ] && args+=(--version "$version")
    local channel
    channel=$(current_channel)
    [ -n "$channel" ] && args+=(--channel "$channel")
    if [ "$allow" = "1" ] || [ "$allow" = "true" ]; then
        # The reason is checked here as well as in corectl. This script is the
        # thing between a compromised primary and root on this machine, and a
        # rule it only relays is a rule it does not hold.
        if [ -z "$reason" ]; then
            echo "a downgrade needs a reason; refusing"
            return 1
        fi
        args+=(--allow-downgrade --downgrade-reason "$reason")
    elif [ -n "$reason" ]; then
        echo "a downgrade reason was given without asking for a downgrade; refusing"
        return 1
    fi

    local log rc=0
    log=$(mktemp)
    corectl "${args[@]}" >"$log" 2>&1 || rc=$?
    # Everything corectl said goes to the journal, whatever the outcome. The
    # answer the primary gets is one line by design; which manifest verified,
    # under which key, and that the bytes matched is what an operator reads on
    # the machine afterwards, and it would otherwise be thrown away on exactly
    # the runs that went well.
    journal "$log"
    if [ "$rc" != 0 ]; then
        # The refusal sentence is the last thing corectl prints, and it is the
        # evidence the primary needs: which of the three checks failed, on which
        # package, and what the signed manifest said.
        local why
        why=$(grep -v '^$' "$log" | tail -3 | tr '\n' ' ')
        rm -f "$log"
        echo "${why:-corectl fleet-update failed with no output}"
        return 1
    fi
    local summary
    summary=$(sed -n 's/^fleet-update: //p' "$log" | tail -1)
    rm -f "$log"
    echo "${summary:-corectl fleet-update said nothing}"
}

do_refresh_entitlement() {
    local machine=$1 login=$2 password=$3 base=$4
    [ -n "$machine" ] && [ -n "$login" ] && [ -n "$password" ] || {
        echo "the entitlement is incomplete"; return 1; }
    one_line "entitlement host" "$machine" || return 1
    one_line "entitlement login" "$login" || return 1
    # The password is checked for the same reason and is never named in the
    # answer: `one_line` prints what is wrong with a value, not the value.
    one_line "entitlement password" "$password" || return 1
    install -d -m 0755 "$(dirname "$APT_AUTH")"
    local tmp
    tmp=$(mktemp)
    printf 'machine %s\nlogin %s\npassword %s\n' "$machine" "$login" "$password" >"$tmp"
    install -m 0600 "$tmp" "$APT_AUTH"
    rm -f "$tmp"
    local channel
    channel=$(current_channel)
    [ -n "$channel" ] || channel=stable
    write_sources "$channel" "$base" || return 1
    park_open_source
    echo "entitlement for $login at $machine, channel $channel"
}

do_revoke_entitlement() {
    rm -f "$APT_AUTH"
    # The sources file is left in place on purpose. Removing it would hide the
    # fact that this machine used to be entitled, and an operator running
    # `apt-get update` after a revocation should see a 401 from a repository
    # they recognise rather than silence.
    echo "the apt credential is removed; data, customers and certificates are untouched"
}

# park_open_source moves an unauthenticated CoreCP apt source out of the way.
#
# A managed panel installs THROUGH its entitlement, not around it. Leaving the
# open source in place would make revocation cosmetic — apt would simply take
# the same packages from the door that has no lock — and would make every test
# of the licence a test of nothing.
#
# Parked rather than deleted, and with the same suffix `corectl` uses when it
# parks the distribution's own sources, so `--os-mirror off` and this read the
# same way and neither destroys what it displaced.
park_open_source() {
    local open=/etc/apt/sources.list.d/corecp.sources
    [ -f "$open" ] || return 0
    mv -f "$open" "$open.corecp-parked"
    echo "parked $open (a managed panel installs through its entitlement)" >&2
}

# ---------------------------------------------------------------------------
# the drain
# ---------------------------------------------------------------------------

[ -d "$SPOOL" ] || exit 0
shopt -s nullglob
for req in "$SPOOL"/req-*.json; do
    id=$(field "$req" id)
    verb=$(field "$req" verb)
    [ -n "$id" ] || { log "a request with no id: $req"; rm -f "$req"; continue; }
    [ -e "$SPOOL/res-$id.json" ] && { rm -f "$req"; continue; }

    detail=""
    ok=0
    case "$verb" in
        update.set_channel)
            detail=$(do_set_channel "$(field "$req" channel)" 2>&1) && ok=1 ;;
        update.apply)
            detail=$(do_apply "$(field "$req" version)" "$(field "$req" allow_downgrade)" \
                "$(field "$req" downgrade_reason)" "$(field "$req" actor)" 2>&1) && ok=1 ;;
        entitlement.refresh)
            detail=$(do_refresh_entitlement \
                "$(field "$req" machine)" "$(field "$req" login)" \
                "$(field "$req" password)" "$(field "$req" base_url)" 2>&1) && ok=1 ;;
        entitlement.revoke)
            detail=$(do_revoke_entitlement 2>&1) && ok=1 ;;
        *)
            # Everything else, including the verbs that need no privilege
            # (update.set_wave, update.hold, update.resume — the panel applies
            # those itself) and anything a future primary invents. A request
            # this script does not recognise is refused, not passed through.
            detail="corecp-fleet-apply does not implement $verb" ;;
    esac
    log "$verb -> $([ "$ok" = 1 ] && echo ok || echo failed): $detail"
    answer "$id" "$ok" "$detail"
    rm -f "$req"
done
