#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Brad Anderson
#
# Watches a devbox from a machine that is not the devbox.
#
# THE INVERSION IS THE WHOLE DESIGN. dejima-guard reaches in; the watched host
# never reaches out. That falls out of the firewall the devbox already has -
# inbound SSH from the admin subnet is permitted, so this needs no new rule -
# and it buys three things at once:
#
#   - the baseline lives here, on a machine the devbox cannot reach. Root there
#     can lie about the current state; it cannot alter what was recorded last
#     week;
#   - the alert leaves from here, so a compromised devbox cannot suppress it by
#     staying quiet or drown it by shouting;
#   - SILENCE BECOMES A SIGNAL. A push reporter that is switched off causes
#     nothing to happen, forever. A pull that stops arriving is itself the
#     alarm, and it is the only mechanism here a compromised box cannot defeat
#     by doing nothing.
#
# It is not a service. No port, no daemon, no inbound anything: a timer runs
# it, it makes a few outbound connections, writes a file and exits. That is why
# it does not need a machine of its own, and why "another thing to harden" is
# not the trade being made.
#
# WHAT IT HOLDS is the new concentration of privilege and is worth stating: an
# SSH key to the devbox, and a token for whatever it alerts through. Neither
# grants a shell on the hypervisor. The key should be its own, restricted by
# command= in authorized_keys, rather than a general-purpose administrative one.

set -euo pipefail

PROG=${0##*/}
CONF=${DEJIMA_GUARD_CONF:-/etc/dejima-guard/config}
STATE_DIR=${DEJIMA_GUARD_STATE:-/var/lib/dejima-guard}

say() { printf '%s\n' "$*" >&2; }
die() { say "$PROG: $*"; exit 2; }

# --- config -----------------------------------------------------------------
#
# Parsed, never sourced. This file names a host to SSH to and a URL to post to,
# and sourcing it would make both of those arbitrary code from a file that has
# no reason to be executable.
# Environment first, then the file, then the default.
#
# The environment is for containers, where a mounted config file is friction
# and a compose file is where everything else about the deployment already
# lives. DEJIMA_GUARD_NTFY_URL sets ntfy_url, and so on.
#
# Prefer the file for anything secret. An environment variable is visible in
# `docker inspect` and to anything that can read /proc, which a token in a
# 0640 file is not - so the token is the one value worth mounting even when
# everything else comes from compose.
cfg() {  # cfg KEY [DEFAULT]
    local v="" env_name
    env_name="DEJIMA_GUARD_$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
    v=$(printf '%s' "${!env_name-}")
    [ -n "$v" ] || { [ -r "$CONF" ] && v=$(sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$CONF" | tail -1); }
    [ -n "$v" ] || v="${2-}"
    printf '%s\n' "$v"
}

# The same restriction dejima puts on its own notify URL, for the same reason:
# curl reads a leading -K/path as an option that takes the request from a file,
# so a value without an explicit scheme is refused rather than guessed at.
#
# THE CHARACTER SET IS RFC 3986's, not a shorter list that looked sufficient.
# The shorter list omitted "&", which every URL with two query parameters has -
# including the push URL Uptime Kuma hands out, which is the one heartbeat_url
# is documented to hold. So the setting this project recommends was rejected by
# the project that recommends it.
#
# Refusing a legitimate URL is not the safe direction here. It costs the
# heartbeat, and the heartbeat is the one signal a compromised devbox cannot
# suppress - so an over-strict rule quietly removes the control that does not
# depend on the watched machine cooperating.
check_url() {  # check_url NAME VALUE
    local bad
    case "$2" in
        http://*|https://*) ;;
        *) die "$1 must start with http:// or https:// - got '$2'" ;;
    esac

    # Whitespace and control characters first, and separately, because the test
    # below cannot see a trailing one: command substitution strips trailing
    # newlines, so a value ending in one leaves an empty result and reads as
    # clean. A newline is also the character worth being surest about, being the
    # one that turns a single argument into two lines of something.
    case "$2" in
        *[[:space:]]*)  die "$1 contains whitespace" ;;
        *[[:cntrl:]]*)  die "$1 contains a control character" ;;
    esac

    # Deleting the permitted characters leaves the offending ones, which is what
    # the operator needs. Naming neither the character nor the setting's value
    # left "contains a character that is not allowed here" as the whole of the
    # report, and finding which one meant guessing.
    #
    # unreserved, gen-delims, sub-delims and % - everything a URI may contain.
    # What remains banned is what cannot appear in one anyway: whitespace,
    # control characters, quotes, backslash, and < > ^ ` { | }.
    bad=$(printf '%s' "$2" | tr -d "A-Za-z0-9:/?#@[]!\$&'()*+,;=._~%-")
    [ -z "$bad" ] || die "$1 contains characters that cannot appear in a URL: $bad"
}

# --- reaching the devbox ----------------------------------------------------

# The two commands sent are matched EXACTLY by dejima-guard-shell, the forced
# command on the devbox's end of this key. Change either string and the other
# side refuses it - which arrives here as a failed collection, reported as the
# devbox being unreachable. Both repositories, one contract.
ssh_devbox() {  # ssh_devbox COMMAND...
    local host user key known opts
    host=$(cfg devbox_host) ; [ -n "$host" ] || die "no devbox_host in $CONF"
    user=$(cfg devbox_user) ; [ -n "$user" ] || die "no devbox_user in $CONF"
    key=$(cfg ssh_key)
    known=$(cfg ssh_known_hosts)
    opts="-o BatchMode=yes -o ConnectTimeout=$(cfg ssh_timeout 10)"

    # StrictHostKeyChecking is left at the ssh default on purpose. Turning it
    # off would mean this program accepts a changed host key without comment -
    # on the one connection whose whole point is noticing that something about
    # that machine changed.
    #
    # Which makes WHERE known_hosts lives a real setting rather than a detail.
    # In a container the filesystem is new every run, so there is no
    # known_hosts, and BatchMode turns the prompt into a failure: the first
    # collection reports "could not reach the devbox" at max priority, and so
    # does every one after it. A false alarm that never stops is worse than no
    # alarm, because it is the one that gets muted.
    #
    # So it points at the state volume, where it persists and where a change to
    # it is a change somebody has to explain.
    [ -n "$known" ] && opts="$opts -o UserKnownHostsFile=$known"

    # IdentitiesOnly, and it is the difference between the restriction being
    # real and being decorative.
    #
    # `-i` ADDS a key to the ones offered; it does not restrict to it. So on a
    # machine where the operator has their own key to the devbox - or an agent
    # holding one - ssh can authenticate with that instead, and the forced
    # command attached to the guard's key never applies. Everything works. The
    # guard collects, the alerts arrive, and the restriction protects nothing,
    # with nothing anywhere saying so.
    #
    # Found on a real devbox: the installer's own check reported the forced
    # command not in force, because it had the same bug.
    [ -n "$key" ] && opts="$opts -o IdentitiesOnly=yes"
    # shellcheck disable=SC2086
    ssh $opts ${key:+-i "$key"} "$user@$host" "$@"
}

# Everything the devbox said, made safe to store and to print.
#
# THE REPORT IS AN UNTRUSTED DOCUMENT. The alert bodies are composed here from
# counts, and that rule was enforced - but the alert exists to say go and read
# the report, and the report is the devbox's own words: file paths, unit names,
# and whatever ssh had to say. A path is a filename, and a filename may contain
# an escape sequence.
#
# `dejima-guard report` cats that to a terminal, on the machine holding the SSH
# key to the box the report is about. ESC[2K ESC[A erases the line above and
# writes over it, so a compromised devbox could name a file such that the
# evidence of it scrolls past as an all-clear. Every byte of that path came from
# the machine under suspicion.
#
# Control characters become "?" rather than being deleted: a filename with an
# escape sequence in it is itself a finding, and one that vanished silently
# would be the second thing this got wrong. Newlines are kept, being the line
# structure rather than content, and anything printable is left alone so a
# legitimate non-ASCII path still reads as itself.
sanitise() { LC_ALL=C tr '\000-\011\013-\037\177' '?'; }

# The watched host's own name, for alerts that would otherwise say "the devbox".
#
# An operator watching more than one box gets pushes that are indistinguishable
# without this, and "the devbox" on a phone at 3am names nothing at all.
#
# SAFE TO INTERPOLATE, unlike almost everything else near an alert body: this
# comes from the guard's own config file, on this machine. It is not something
# the watched host said. That distinction is the whole rule, not the word
# "hostname".
#
# The short form for titles, which a lock screen truncates - a fully qualified
# name eats the whole line and pushes the actual finding off the end. The full
# one in bodies, where there is room to be unambiguous.
target() { cfg devbox_host; }
target_short() { local h; h=$(cfg devbox_host); printf '%s\n' "${h%%.*}"; }

# --- alerting ---------------------------------------------------------------
#
# NOTHING THE DEVBOX CONTROLS REACHES THE PHONE. dejima draws this line for its
# own pushes and states where the guarantee lives; the same rule applies one
# layer out, for the same reason. A compromised devbox controls filenames, unit
# names and process names - every string a naive alert body would interpolate -
# and ntfy renders a URL in a body as something tappable.
#
# So a body is composed here, from a fixed vocabulary and counts. The detail
# stays in the report on this machine. The push exists to say go and look.
# ALERT_FAILED is set when a send does not succeed, and it is why this
# function exists rather than the callers each running curl.
#
# Every call site used to end in `|| true`, so a wrong token, a wrong topic or
# an ntfy that was down produced exactly nothing: the guard found drift, said
# ALERTED, and no alert existed. That is the worst failure this program can
# have - worse than dying, because a dead guard trips the heartbeat and this
# did not.
ALERT_FAILED=0

alert() {  # alert PRIORITY TITLE BODY
    local url tok cmd
    url=$(cfg ntfy_url)
    cmd=$(cfg alert_command)

    if [ -n "$cmd" ]; then
        if printf '%s\n%s\n' "$2" "$3" | $cmd; then return 0; fi
        ALERT_FAILED=1
        say "ALERT NOT SENT: alert_command failed"
        return 1
    fi
    if [ -z "$url" ]; then
        ALERT_FAILED=1
        say "ALERT NOT SENT: no ntfy_url and no alert_command configured"
        return 1
    fi
    check_url ntfy_url "$url"
    tok=$(cfg ntfy_token)
    if curl -fsS --max-time 15 \
        ${tok:+-H "Authorization: Bearer $tok"} \
        -H "Title: $2" -H "Priority: $1" -H "Tags: rotating_light" \
        -d "$3" "$url" >/dev/null
    then
        return 0
    fi
    ALERT_FAILED=1
    say "ALERT NOT SENT: publishing to $url failed. Wrong token, wrong topic,"
    say "  or the server is unreachable. The finding below went nowhere."
    return 1
}

# The dead-man's switch, and the only part of this a compromised devbox cannot
# reach at all.
#
# Uptime Kuma's push monitor is the documented shape: it expects a ping on a
# cadence and alerts when one does not arrive. So this pings only on a cycle
# that completed - never in a failure path - and the absence of that ping is
# what reports a guard that has died, been switched off, or lost its network.
#
# Anything speaking the same "GET this URL to say you are alive" shape works
# here. Only the one is documented, because a list of alternatives is a list
# that goes stale.
heartbeat() {
    local url
    url=$(cfg heartbeat_url)
    [ -n "$url" ] || return 0
    check_url heartbeat_url "$url"
    curl -fsS --max-time 10 "$url" >/dev/null || say "heartbeat failed to send"
}

# --- the cycle ---------------------------------------------------------------

BASE="$STATE_DIR/baseline"
LAST="$STATE_DIR/last-report"

# Categories rather than paths, because the body of an alert must carry nothing
# the watched machine chose. A count of changed unit files is safe to send; the
# name of one is not.
summarise() {  # summarise DIFF_FILE
    local f=$1 n_file n_unit n_port
    n_file=$(grep -c '^[+-] FILE ' "$f" || true)
    n_unit=$(grep -c '^[+-] UNIT ' "$f" || true)
    n_port=$(grep -c '^[+-] PORT ' "$f" || true)
    [ "$n_file" -gt 0 ] && printf '%s file(s)\n' "$n_file"
    [ "$n_unit" -gt 0 ] && printf '%s systemd unit(s)\n' "$n_unit"
    [ "$n_port" -gt 0 ] && printf '%s listening port(s)\n' "$n_port"
    return 0
}

# Checked here rather than at the point of use.
#
# ssh_devbox runs inside a command substitution, and `exit` in there ends the
# subshell rather than this program - so a missing host produced an empty
# result and the cycle carried on as though the box had answered with nothing.
require_config() {
    local h
    h=$(cfg devbox_host)
    [ -n "$h" ] || die "no devbox_host in $CONF - nothing to watch"
    # No default for the account, deliberately.
    #
    # It used to default to root, on a machine whose own runbook builds it with
    # no root password and expects sudo from an ordinary account - so the
    # default was wrong for every devbox built the documented way. Defaulting
    # to nothing means ssh would use the local username instead, which in a
    # container is an account that does not exist over there. Say which.
    [ -n "$(cfg devbox_user)" ] || die "no devbox_user in $CONF - which account does the guard connect as?"
    # The placeholder the shipped example carries, refused so that example can
    # never become a live config by being copied and forgotten. dejima's own
    # nginx example takes the same approach for the same reason.
    case "$h" in
        CHANGE-ME*) die "host is still the example placeholder ($h) - edit your config" ;;
    esac
    # The key is checked here, where the answer is certain, rather than left to
    # ssh - which reports a key it could not open as
    # "Permission denied (publickey,password)", indistinguishable from a devbox
    # that rejected it. That sends an operator to the far end to debug a file on
    # this one.
    #
    # Containers make it the expected first failure rather than an unlikely one.
    # This image runs as its own uid, compose bind-mounts a file secret with the
    # host's ownership and mode, and a private key is 0600 to somebody else - so
    # a correctly built deployment cannot read its own key until the ownership
    # is made to match.
    local k
    k=$(cfg ssh_key)
    if [ -n "$k" ]; then
        [ -e "$k" ] || die "ssh_key $k does not exist"
        if [ ! -r "$k" ]; then
            say "$PROG: cannot read ssh_key $k"
            say "  this runs as uid $(id -u); the file is uid $(stat -c %u "$k" 2>/dev/null || echo '?')," \
                "mode $(stat -c %a "$k" 2>/dev/null || echo '?')"
            say ""
            # Phrased around the uid this is actually running as, which is the
            # right answer whether that is a container's own account or an
            # operator's. Claiming "the image runs as" would be false half the
            # time, in a message whose only job is to be believed.
            say "  The key has to be readable by the account this runs as. In a"
            say "  container that is not yours: compose mounts the secret with"
            say "  the host's ownership. On the host, beside your compose file:"
            say ""
            say "    sudo chown $(id -u):$(id -g) secrets/guard_key secrets/config"
            exit 2
        fi
    fi

    local u
    for u in ntfy_url heartbeat_url; do
        [ -n "$(cfg "$u")" ] && check_url "$u" "$(cfg "$u")"
    done
    return 0
}

cmd_check() {
    local rc=0 state now reach_rc reach_out changed=0 body=""
    require_config
    install -d -m 0750 "$STATE_DIR"
    now=$(mktemp) || die "no temp space"

    # 1. Can it be reached at all?
    #
    # Deliberately the first question. A devbox that does not answer is the
    # case a push reporter cannot report, and it is why this program pulls.
    local ssh_err
    ssh_err=$(mktemp) || die "no temp space"
    # shellcheck disable=SC2064
    trap "rm -f '$now' '$now.diff' '$ssh_err'" EXIT

    # ssh's own words are kept, not sent to /dev/null. They were, and the alert
    # offered three guesses instead - so the one failure that stops this program
    # working at all arrived with the reason already thrown away.
    #
    # LOCALLY ONLY. This text is the one thing near an alert that the far end
    # has any say in, so it goes to the log and the report on this machine and
    # never into the push.
    if ! state=$(ssh_devbox dejima-devbox-audit drift --state 2>"$ssh_err"); then
        sanitise < "$ssh_err" > "$ssh_err.clean" && mv "$ssh_err.clean" "$ssh_err"
        alert max "$(target_short): unreachable" \
            "dejima-guard could not collect from $(target). It may be down, off the network, or refusing this key." || true
        say "FAIL: could not reach $(target). ssh said:"
        sed 's/^/  /' "$ssh_err" >&2

        # Host key verification is not "the box is down", and the remedy is a
        # command rather than an investigation. Untreated it is permanent: every
        # cycle fails the same way at max priority, which is the false alarm that
        # gets a topic muted. It is also the expected state of a fresh container,
        # whose known_hosts starts empty.
        if grep -q 'Host key verification failed' "$ssh_err"; then
            say ""
            say "  That is the host key, not the network. This guard has never"
            say "  seen $(target)'s key, or has seen a different one."
            say "    $PROG trust-host"
            say "  which shows the fingerprint before writing it down."
        fi
        return 1
    fi

    # Tested on the string, not on the file. `printf '%s\n' ""` writes a
    # newline, so a file holding nothing useful is still one byte long and
    # passes -s - which had an audit that returned nothing read as a valid
    # state, and on a first run recorded it as the baseline.
    if [ -z "$state" ]; then
        alert max "$(target_short): audit returned nothing" \
            "The audit on $(target) ran and produced no state. That is not a clean box; it is a check that did not run." || true
        say "FAIL: empty state"
        return 1
    fi
    # Sanitised after capture, not piped through on the way in: a pipeline would
    # put tr's status where ssh's needs to be, and reading the wrong end of a
    # pipeline is a mistake this program has already made once.
    state=$(printf '%s\n' "$state" | sanitise)
    printf '%s\n' "$state" > "$now"

    # 2. Containment, which the devbox can answer for itself.
    reach_rc=0
    reach_out=$(ssh_devbox dejima-devbox-audit reach 2>&1) || reach_rc=$?
    reach_out=$(printf '%s\n' "$reach_out" | sanitise)

    # 3. Drift, which it cannot: the baseline is here.
    if [ ! -f "$BASE" ]; then
        cp "$now" "$BASE"
        say "First run. Baseline recorded from the devbox as it is now."
        say "  $(grep -c . "$BASE") entries. Take it on a box you believe is clean."
        heartbeat
        return 0
    fi

    diff --unchanged-line-format= --old-line-format='- %L' --new-line-format='+ %L' \
        "$BASE" "$now" > "$now.diff" || true

    if [ -s "$now.diff" ]; then
        changed=1
        body=$(summarise "$now.diff" | paste -sd', ' -)
    fi

    # 4. One alert per cycle, batching every finding.
    #
    # Not one per finding: an apt upgrade would otherwise arrive as forty
    # pushes, and the next real one lands in a topic already muted.
    if [ "$changed" = 1 ] && [ "$reach_rc" -ne 0 ]; then
        alert max "$(target_short): changes and containment" \
            "On $(target): $body changed, and the containment check did not pass." || true
        rc=1
    elif [ "$changed" = 1 ]; then
        alert high "$(target_short): unexplained changes" \
            "On $(target): $body changed since the last accepted state." || true
        rc=1
    elif [ "$reach_rc" -ne 0 ]; then
        alert high "$(target_short): containment" \
            "Nothing changed on $(target), but the containment check did not pass." || true
        rc=1
    fi

    {
        printf '%s\n' "--- collected $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
        printf 'reach exit: %s\n%s\n\n' "$reach_rc" "$reach_out"
        if [ "$changed" = 1 ]; then
            printf 'changes against the baseline:\n'; cat "$now.diff"
        else
            printf 'no changes against the baseline\n'
        fi
    } > "$LAST"

    if [ "$ALERT_FAILED" = 1 ]; then
        # No heartbeat, deliberately.
        #
        # A guard that has found something and cannot tell anyone is, for every
        # practical purpose, a guard that is not running - and the one channel
        # left that can say so is the one that reports silence. Letting the
        # heartbeat through here would have Uptime Kuma report all is well on
        # the single cycle where it is least true.
        say "FINDINGS WERE NOT DELIVERED. Suppressing the heartbeat so the"
        say "  dead-man's switch reports this guard as down, which is what it"
        say "  effectively is. See $LAST for what it could not send."
        return 1
    fi

    if [ "$rc" -eq 0 ]; then
        say "CLEAN: no changes, containment passed."
        # Only on a cycle that completed and found nothing wrong. Pinging in a
        # failure path would tell the dead-man's switch that all is well at the
        # exact moment it is not.
        heartbeat
    else
        say "ALERTED: see $LAST"
    fi
    return $rc
}

# Accepting the current state is a decision, so it is a separate command and
# never something a check does on its own.
cmd_accept() {
    local state
    require_config
    install -d -m 0750 "$STATE_DIR"
    state=$(ssh_devbox dejima-devbox-audit drift --state) || die "could not collect from the devbox"
    [ -n "$state" ] || die "the devbox returned nothing - not accepting that as a baseline"
    state=$(printf '%s\n' "$state" | sanitise)
    printf '%s\n' "$state" > "$BASE"
    say "Baseline accepted: $(grep -c . "$BASE") entries in $BASE"
    say "  Take it on a box you believe is clean. One taken on a compromised"
    say "  machine records the compromise as normal and reports clean forever."
}

# The alert path is the one thing here nothing else exercises.
#
# A clean cycle never publishes, so a wrong token or a wrong topic sits
# undiscovered until the first real finding - and then takes it with it. There
# is no way around that from in here: whether a push arrived is a question only
# the phone in your pocket can answer.
#
# So it is a command, and the setup says to run it and go and look. Verifying
# it is a step somebody performs, not a claim this program can make.
cmd_test_alert() {
    require_config
    say "Sending a test alert."
    if alert default "dejima-guard: test" \
        "If this arrived, the alert path works. Nothing is wrong with $(target)."
    then
        say ""
        say "Sent. GO AND CHECK IT ARRIVED."
        say "  Sent is not received: the server accepted it, which says nothing"
        say "  about whether your phone is subscribed to this topic."
        return 0
    fi
    say ""
    say "  Until this works, a finding about the devbox goes nowhere."
    return 1
}

cmd_report() { [ -r "$LAST" ] && cat "$LAST" || say "no report yet - run $PROG check"; }

# Records the devbox's host key, because nothing else here ever did.
#
# THIS WAS A HOLE, not a convenience. StrictHostKeyChecking is deliberately left
# on, and BatchMode turns its prompt into a failure - so a guard whose
# known_hosts is empty cannot connect at all, and reports "unreachable" at max
# priority on every cycle forever. A fresh container is exactly that state. The
# code said so in a comment and pointed known_hosts at a volume where it would
# persist; nothing put anything in it. dejima-guard-install-key connects as the
# operator and so uses the operator's own known_hosts, on a different account
# and usually a different filesystem.
#
# It stays a separate command rather than something check does when it fails.
# Learning a key automatically on the connection whose purpose is noticing that
# machine changed would defeat the check it is meant to support.
cmd_trust_host() {
    local host known keys existing want=""
    [ "${1:-}" = "--fingerprint" ] && { want="${2:?--fingerprint needs a value}"; }
    host=$(target); [ -n "$host" ] || die "no devbox_host in $CONF"
    known=$(cfg ssh_known_hosts); [ -n "$known" ] \
        || die "no ssh_known_hosts in $CONF - nowhere to write this"
    command -v ssh-keyscan >/dev/null || die "ssh-keyscan is not installed here"

    install -d -m 0750 "$(dirname "$known")"
    keys=$(ssh-keyscan -T 10 "$host" 2>/dev/null) \
        || die "could not fetch a host key from $host"
    [ -n "$keys" ] || die "$host offered no host key - is it up, and is 22 open from here?"

    # An existing entry is never quietly replaced. If this host's key has
    # changed, that is either a rebuild or the thing this whole program exists
    # to notice, and neither is for a helper to decide.
    existing=""
    [ -s "$known" ] && existing=$(ssh-keygen -F "$host" -f "$known" 2>/dev/null | grep -v '^#' || true)
    if [ -n "$existing" ]; then
        # Every key already trusted must still be one this host offers. Set
        # equality is the wrong test: ssh-keyscan returns every type the server
        # supports, while known_hosts usually holds the one type ssh negotiated,
        # so equality would call an unchanged host changed.
        #
        # Key material only, by position from the end. Both formats put the type
        # and the blob last, which keeps this working for a hashed known_hosts
        # where the first field is not a hostname at all.
        blob() { awk 'NF>=3 {print $(NF-1), $NF}' | sort -u; }
        local have want_set
        have=$(printf '%s\n' "$existing" | blob)
        want_set=$(printf '%s\n' "$keys" | blob)

        # An entry that parsed as nothing is not an entry that matches. comm
        # against an empty set returns empty, which reads identically to "every
        # key already trusted is still offered" - so a malformed known_hosts
        # line would report the host trusted and leave the guard unable to
        # connect, with this command insisting there was nothing to do.
        if [ -z "$have" ]; then
            say "$known has an entry for $host that could not be read."
            say "  Remove it and run this again:"
            say "    ssh-keygen -R $host -f $known"
            return 1
        fi

        if [ -z "$(comm -23 <(printf '%s\n' "$have") <(printf '%s\n' "$want_set"))" ]; then
            say "$host is already trusted, with a key it is still offering."
            return 0
        fi
        say "$host ALREADY HAS A DIFFERENT KEY in $known."
        say ""
        say "  Either that machine was rebuilt, or something is answering for it."
        say "  This will not choose between those. If you know it was rebuilt:"
        say "    ssh-keygen -R $host -f $known"
        say "  and run this again."
        return 1
    fi

    # Computed once, and its failure handled rather than inherited. Piping
    # straight into sed put ssh-keygen's status at the head of a pipeline under
    # `set -e` with pipefail, so a key it could not read killed this command
    # outright: exit 255, no message, after the header and before the write.
    local fprints=""
    fprints=$(printf '%s\n' "$keys" | ssh-keygen -lf - 2>/dev/null) || fprints=""

    if [ -n "$fprints" ]; then
        say "$host offers:"
        printf '%s\n' "$fprints" | sed 's/^/  /' >&2
    else
        say "$host answered, but ssh-keygen here could not fingerprint what it"
        say "  sent. That is odd enough to look at before trusting it."
        # Fails closed. A fingerprint that cannot be computed cannot be
        # compared, and treating that as a pass would make --fingerprint a
        # flag that verifies nothing in exactly the case it matters.
        [ -n "$want" ] && { say "NOT WRITTEN: nothing to check your fingerprint against."; return 1; }
    fi
    say ""

    if [ -n "$want" ]; then
        if ! printf '%s\n' "$fprints" | awk '{print $2}' | grep -qxF "$want"; then
            say "NOT WRITTEN: none of those match the fingerprint you gave."
            say "  expected: $want"
            return 1
        fi
        say "Matches the fingerprint you gave."
    else
        # Said plainly rather than implied. Without a fingerprint to check
        # against, this trusts whatever answered - which is the same trust an
        # operator extends by typing "yes" at a prompt, and worth naming as
        # such rather than dressing up as verification.
        say "NOT VERIFIED. Nothing was checked against this; whatever answered"
        say "  is what gets written. On the devbox itself:"
        say "    ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub"
        say "  and compare, or re-run with --fingerprint SHA256:..."
    fi

    printf '%s\n' "$keys" >> "$known"
    chmod 0640 "$known" 2>/dev/null || true
    say ""
    say "Written to $known. $PROG check should now reach $host."
}

usage() {
    cat >&2 <<USAGE
usage: $PROG [check|accept|report|test-alert|trust-host]

  check       collect from the devbox, diff against the baseline held here,
              alert on any change or on containment failing, and send the
              heartbeat only if the cycle completed clean and could alert
  accept      record the devbox's current state as the baseline
  report      the last cycle's detail, which is deliberately not in the alert
  test-alert  publish a test alert, then go and check it arrived. Nothing
              else here exercises that path until it is needed
  trust-host  record the devbox's SSH host key, which every other command
              needs and nothing else writes. Run once, before the first check
              [--fingerprint SHA256:...] to check it before writing it down
USAGE
}

case "${1:-check}" in
    check)      cmd_check ;;
    accept)     cmd_accept ;;
    report)     cmd_report ;;
    test-alert) cmd_test_alert ;;
    trust-host) shift; cmd_trust_host "$@" ;;
    -h|--help) usage ;;
    *) say "$PROG: unknown command: $1"; usage; exit 64 ;;
esac
