#!/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.
# The config names a command to run (alert_command), so write access to it is
# code execution as whoever this runs as. That makes its mode part of the
# security of the thing rather than tidiness, and worth refusing rather than
# noting - ssh takes the same line about a private key, for the same reason.
#
# Writable is what is checked, not readable: the container path mounts this file
# with the host's ownership, and group-readable is normal there.
check_conf_perms() {
    local mode
    [ -f "$CONF" ] || return 0
    mode=$(stat -c %a "$CONF" 2>/dev/null) || return 0
    # Masked arithmetic, not a pattern on the digits. Matching the last
    # character tests other-write and misses group-write entirely, which is the
    # half a shared machine is most likely to have.
    if [ $(( 8#$mode & 0022 )) -ne 0 ]; then
        die "$CONF is writable by group or others (mode $mode).
It names a command this program runs, so that is code execution as $(id -un).
  chmod 0640 $CONF"
    fi
}

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

# A value that is about to be written into a curl config file.
#
# check_url already refuses a URL containing a quote or a backslash, which is
# what would end the quoted value early and let the rest be read as further
# options. A token goes through the same door and needs the same lock: curl
# config syntax is not shell, but it is still a parser being handed a value
# from a file.
check_secret() {  # check_secret NAME VALUE
    [ -n "$2" ] || return 0
    case "$2" in
        *[\"\\]*|*"'"*) die "$1 contains a quote or a backslash, which cannot be passed safely" ;;
    esac
    case "$2" in
        *[[:space:]]*) die "$1 contains whitespace" ;;
    esac
}

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

    # Named rather than inherited. ssh reads /etc/ssh/ssh_config and the running
    # account's ~/.ssh/config, neither of which this project owns, on a machine
    # whose hardening it describes nothing about - and this is a connection to a
    # host the whole design assumes may be hostile.
    #
    # These four are the ones that let the local config or the far end widen
    # what the connection can do: an agent to borrow, a local command to run, a
    # forwarding to open, a multiplexed socket to ride in on. Set here rather
    # than by -F /dev/null, which would also discard a legitimate ProxyJump the
    # operator needs to reach the devbox at all.
    opts="$opts -o ForwardAgent=no -o ClearAllForwardings=yes"
    opts="$opts -o PermitLocalCommand=no -o ControlMaster=no -o ControlPath=none"
    # 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' '?'; }

# Collection, with a ceiling on how much the watched host may send.
#
# The devbox chose the size and nothing capped it: the answer went into a shell
# variable, then a file, then both sides of a diff. A compromised devbox could
# return as much as it liked, which is memory pressure and a full /var/lib on a
# machine that is running other things. It is the clearest thing the watched
# host can do to the guard's host.
#
# It never defeated the watching - a guard that dies stops sending the heartbeat
# and the dead-man's switch says so - so this is about blast radius on the host
# rather than about detection.
#
# An over-long answer is a FINDING, not data to truncate and diff. Truncating
# would produce a state that differs from the baseline in a way the devbox
# controls, which is a way to manufacture alerts or, worse, to cut the state off
# just before whatever was added.
#
# pipefail is off for the pipeline on purpose. `head` closing the pipe sends
# SIGPIPE to ssh, so with pipefail a capped read reports as a failed connection
# - the one reading that sends an operator to the wrong end. The size is checked
# first and ssh's own status is read from PIPESTATUS.
collect() {  # collect DEST_FILE ERR_FILE; 0 ok, 1 too big, 2 ssh failed
    local cap rc
    cap=$(cfg max_state_bytes 8388608)
    set +o pipefail
    ssh_devbox dejima-devbox-audit drift --state 2>"$2" \
        | head -c "$((cap + 1))" > "$1"
    rc=${PIPESTATUS[0]}
    set -o pipefail
    [ "$(wc -c < "$1")" -le "$cap" ] || return 1
    [ "$rc" -eq 0 ] || return 2
    return 0
}

# 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)
    check_secret ntfy_token "$tok"

    # THE TOKEN STAYS OUT OF argv. /proc/<pid>/cmdline is world-readable, so
    # `-H "Authorization: Bearer $tok"` hands the token to every local user for
    # the length of the call - and on the systemd path this runs as root on a
    # machine that may have other people on it. curl reads options from a config
    # on stdin instead, which no other process can see.
    #
    # The body and the title stay on the command line: they are counts and a
    # hostname, and neither is a secret.
    if { [ -n "$tok" ] && printf 'header = "Authorization: Bearer %s"\n' "$tok"; true; } \
        | curl -fsS --max-time 15 -K - \
        -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"
    # Off argv for the same reason: an Uptime Kuma push URL carries its token in
    # the path, so the URL is the credential.
    printf 'url = "%s"\n' "$url" \
        | curl -fsS --max-time 10 -K - >/dev/null || say "heartbeat failed to send"
}

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

BASE="$STATE_DIR/baseline"
LAST="$STATE_DIR/last-report"
# The most recent cycle that FOUND something, kept beside the most recent cycle.
#
# last-report is overwritten every cycle, so a clean one erases the evidence of
# the alert before it. On anything that alternates - and a flapping line
# alternates by definition - an operator who runs `report` after an alert lands
# is most likely to catch a clean cycle and see "no changes against the
# baseline", which reads as the alert having been imaginary.
FINDING="$STATE_DIR/last-finding"
# The end of an expected-changes window, in epoch seconds. See cmd_expect.
WINDOW="$STATE_DIR/expect-until"

# Sorted here rather than trusted to arrive sorted, so a baseline this program
# rewrote compares line for line with what the devbox sends next.
statediff() {  # statediff OLD NEW
    diff --unchanged-line-format= --old-line-format='- %L' --new-line-format='+ %L' \
        <(LC_ALL=C sort "$1") <(LC_ALL=C sort "$2") || true
}

# One line per thing that changed: STATUS<TAB>KIND<TAB>KEY.
#
# An edited file is two diff lines, its old hash removed and its new one added,
# which read as a deletion and a creation. Paired by what the line is about, an
# edit says "modified".
changes() {  # changes DIFF_FILE
    awk '
        {
            sign = substr($0, 1, 1); kind = $2; key = $0
            sub(/^[+-] [A-Z]+ /, "", key)
            if (kind == "FILE") sub(/^[^ ]+ +/, "", key)
            else if (kind == "LINK") sub(/ -> .*/, "", key)
            id = kind SUBSEP key
            if (!(id in seen)) order[++n] = id
            seen[id] = seen[id] sign
        }
        END {
            for (i = 1; i <= n; i++) {
                split(order[i], p, SUBSEP); s = seen[order[i]]
                st = (s ~ /-/ && s ~ /\+/) ? "modified" : (s ~ /\+/ ? "added" : "removed")
                printf "%s\t%s\t%s\n", st, p[1], p[2]
            }
        }' "$1"
}

changes_table() {  # changes_table DIFF_FILE
    changes "$1" | awk -F '\t' '{ printf "  %-9s %-5s %s\n", $1, $2, $3 }'
}

# What changed, in words this program chose, for the body of an alert.
#
# The devbox names every path, so no path goes in a push. What goes instead is
# the watched area it falls under, printed from the fixed list below and never
# from the path itself: a home directory or filename an intruder chose cannot
# ride along. A count of changed unit files is safe to send; the name of one is
# not.
describe() {  # describe DIFF_FILE
    changes "$1" | awk -F '\t' '
        function area(p) {
            if (p ~ /^\/home\/[^\/]+\/\.ssh\/known_hosts/)        return "~/.ssh/known_hosts"
            if (p ~ /^\/home\/[^\/]+\/\.ssh\//)                   return "~/.ssh"
            if (p ~ /^\/home\/[^\/]+\/\.config\/systemd\/user\//) return "~/.config/systemd/user"
            if (p ~ /^\/home\/[^\/]+\/\.local\/bin\//)            return "~/.local/bin"
            if (p ~ /^\/home\/[^\/]+\/\.[^\/]+$/)                 return "shell startup files"
            if (p ~ /^\/root\/\.ssh\//)                           return "/root/.ssh"
            if (p ~ /^\/var\/spool\/cron\//)                      return "crontabs"
            if (p ~ /^\/etc\/apt\//)                              return "/etc/apt"
            if (p ~ /^\/etc\/systemd\//)                          return "/etc/systemd"
            if (p ~ /^\/etc\/ssh\//)                              return "/etc/ssh"
            if (p ~ /^\/etc\//)                                   return "/etc"
            if (p ~ /^\/usr\/local\/bin\//)                       return "/usr/local/bin"
            if (p ~ /^\/usr\/local\/sbin\//)                      return "/usr/local/sbin"
            return "other paths"
        }
        {
            if ($2 == "UNIT")      g = "unit(s) " ($1 == "added" ? "enabled" : "disabled")
            else if ($2 == "PORT") g = "listening port(s) " ($1 == "added" ? "opened" : "closed")
            else g = ($2 == "LINK" ? "symlink(s) " : "file(s) ") $1 " in " area($3)
            if (!(g in c)) order[++n] = g
            c[g]++
        }
        END {
            for (i = 1; i <= n && i <= 8; i++) printf "%s %s\n", c[order[i]], order[i]
            if (n > 8) printf "%s more kind(s) of change\n", n - 8
        }' | paste -sd ';' - | sed 's/;/, /g'
}

# Said beside a change to known_hosts, which is the one most often harmless and
# least obviously so.
known_hosts_note() {  # known_hosts_note DESCRIPTION
    case "$1" in
        *"~/.ssh/known_hosts"*)
            printf ' known_hosts changes when the devbox first connects to an SSH host it has not seen, such as cloning from a new git server.' ;;
    esac
}

# Where to read the rest. guard_name comes from this machine's config, so it is
# as safe to send as the devbox's own configured name.
details_hint() {
    local g
    g=$(cfg guard_name)
    printf ' Details: %s report%s.' "$PROG" "${g:+, on $g}"
}

# Paths whose change is a different kind of event from a config edit.
#
# The audit's own binary is the sharp one. If it changed, everything this guard
# has been told since is suspect, including the report saying so - and until now
# that arrived as "1 file(s) changed", indistinguishable from a logrotate tweak
# among a hundred others. The forced command, the sudoers rule and the
# authorized_keys line are the same argument one layer out: they are what makes
# the guard's access read-only, so a change to them is a change to what a stolen
# key is worth.
#
# Matched as a substring against the whole diff line, which is a path in every
# line kind this can appear in. The default list names the machinery this design
# rests on; anything else an operator cares about goes in the config.
CRITICAL_DEFAULT='/usr/local/sbin/dejima-devbox-audit
/usr/local/sbin/dejima-guard-shell
/etc/sudoers.d/dejima-guard
/etc/ssh/sshd_config
/.ssh/authorized_keys'

critical_hits() {  # critical_hits DIFF_FILE -> matching lines
    local f=$1 pat
    while IFS= read -r pat; do
        [ -n "$pat" ] || continue
        grep -F -- "$pat" "$f" || true
    done <<< "$(cfg critical_paths "$CRITICAL_DEFAULT")"
}

# Moves the lines an operator has said are not worth waking for out of the
# diff. They stay in the report, under their own heading.
#
# A critical path is never ignored. Ignoring the file that keeps the guard's
# access read-only would be a way to stop watching the guard's own footing.
ignore_split() {  # ignore_split DIFF_FILE IGNORED_FILE
    local pats=$2.pats crit=$2.crit
    cfg ignore_paths | tr -s '[:space:]' '\n' | grep . > "$pats" || true
    critical_hits "$1" > "$crit"
    grep -F -f "$pats" "$1" | grep -vxF -f "$crit" > "$2" || true
    grep -vxF -f "$2" "$1" > "$1.keep" || true
    mv "$1.keep" "$1"
    rm -f "$pats" "$crit"
}

# The baseline an expected-changes window ends with: everything collected now,
# except lines under a critical path, which keep their old value so the check
# still alerts on them. Those are accepted by hand or not at all.
accept_window() {  # accept_window NOW_FILE
    local pats=$1.crit
    cfg critical_paths "$CRITICAL_DEFAULT" | grep . > "$pats" || true
    { grep -vF -f "$pats" "$1"; grep -F -f "$pats" "$BASE"; } \
        | LC_ALL=C sort > "$BASE.new" || true
    rm -f "$pats"
    grep -q . "$BASE.new" || { rm -f "$BASE.new"; return 1; }
    mv "$BASE.new" "$BASE"
}

window_end() {
    local e
    [ -r "$WINDOW" ] || return 0
    e=$(head -1 "$WINDOW")
    case "$e" in ''|*[!0-9]*) return 0 ;; esac
    printf '%s\n' "$e"
}

clock() { date -d "@$1" +'%H:%M %Z'; }

# A listener or an enabled unit that was not there before is not a config edit.
#
# It is the shape of a thing arranging to run again, or to be reached. Both were
# folded into one "unexplained changes" alert at high, beside a checksum that
# moved because logrotate ran.
new_persistence() {  # new_persistence DIFF_FILE
    grep -cE '^\+ (PORT|UNIT) ' "$1" || true
}

# 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
    check_conf_perms
    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 now reach_rc reach_out changed=0 body="" held=0 wend window=none
    require_config
    install -d -m 0750 "$STATE_DIR"
    now=$(mktemp) || die "no temp space"

    # An expected-changes window is open until its end, and closing once it has
    # passed. The check that finds it closing is the one that accepts.
    wend=$(window_end)
    if [ -n "$wend" ]; then
        if [ "$(date +%s)" -lt "$wend" ]; then window=open; else window=closing; fi
    fi

    # 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.raw' '$now.diff' '$now.ignored' '$now.old' '$now.accepted' '$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.
    local craw crc=0
    craw="$now.raw"
    collect "$craw" "$ssh_err" || crc=$?

    if [ "$crc" -eq 1 ]; then
        alert max "$(target_short): oversize response" \
            "The audit on $(target) returned more than this guard will accept. That is not a state; it is a machine sending as much as it can." || true
        say "FAIL: $(target) returned more than $(cfg max_state_bytes 8388608) bytes."
        say "  Nothing was compared. A real state is a few hundred kilobytes."
        return 1
    fi

    if [ "$crc" -ne 0 ]; then
        sanitise < "$ssh_err" > "$ssh_err.clean" && mv "$ssh_err.clean" "$ssh_err"
        # A reboot is part of most updates, so inside a window this is held
        # rather than sent. No heartbeat either way: nothing was checked.
        if [ "$window" = open ]; then
            say "HELD: could not reach $(target), inside the expected-changes window"
            say "  until $(clock "$wend"). ssh said:"
            sed 's/^/  /' "$ssh_err" >&2
            return 1
        fi
        # A window ends at its end, reachable or not. Accepting whatever the box
        # says when it next answers, hours later, is not what was expected.
        if [ "$window" = closing ]; then
            rm -f "$WINDOW"
            say "The expected-changes window has ended with nothing to accept."
        fi
        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

    # Sanitised on the way from the capped capture into the file the diff reads,
    # so the baseline and every diff against it are clean too.
    sanitise < "$craw" > "$now"

    # Tested for a non-empty line rather than a non-empty 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 ! grep -q . "$now"; 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

    # 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.
    # NOT TAKEN HERE, and this is the point of having a separate accept.
    #
    # It used to record one and print a warning. Under the timer that happens
    # five minutes after boot, unattended, quite possibly before anybody has
    # looked at the machine, and the warning goes to a journal nobody is reading
    # yet. Everything this program says afterwards rests on that file being an
    # honest picture of a clean box.
    #
    # No heartbeat either. A guard that has not been told what normal looks like
    # is not watching, and the dead-man's switch is the channel that says so.
    if [ ! -f "$BASE" ]; then
        say "No baseline. Nothing to compare against, so nothing was compared."
        say ""
        say "  Take one deliberately, on a box you believe is clean:"
        say "    $PROG accept"
        say ""
        say "  Until then this reports as down, which is what it is."
        return 1
    fi

    statediff "$BASE" "$now" > "$now.diff"

    local accepted=""
    if [ "$window" = closing ]; then
        cp "$BASE" "$now.old"
        accept_window "$now" || say "Nothing usable to accept as the window closed."
        rm -f "$WINDOW"
        statediff "$now.old" "$BASE" > "$now.accepted"
        statediff "$BASE" "$now" > "$now.diff"
        accepted=$(describe "$now.accepted")
        alert low "$(target_short): expected changes accepted" \
            "The expected-changes window on $(target) has closed. Accepted: ${accepted:-nothing had changed}.$(known_hosts_note "$accepted")" || true
    fi

    ignore_split "$now.diff" "$now.ignored"

    if [ -s "$now.diff" ]; then
        changed=1
        body=$(describe "$now.diff")
    fi
    [ "$window" = open ] && [ "$changed" = 1 ] && held=1

    # 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.
    # Ordered by what the operator has to do about it, most serious first, and
    # still one alert per cycle: an apt upgrade would otherwise arrive as forty
    # pushes and the next real one would land in a muted topic.
    local crit_n=0 newp=0
    crit_n=$(critical_hits "$now.diff" | grep -c . || true)
    newp=$(new_persistence "$now.diff")

    if [ "$changed" = 1 ] && [ "$crit_n" -gt 0 ]; then
        # No path names in the body. The devbox chose those strings, and the
        # count plus the category is enough to say go and read the report.
        alert max "$(target_short): watched path changed" \
            "$crit_n line(s) changed under paths this guard treats as its own footing on $(target), including possibly the audit it collects with. Treat what it has reported since as unverified.$(details_hint)" || true
        rc=1
    elif [ "$changed" = 1 ] && [ "$reach_rc" -ne 0 ]; then
        alert max "$(target_short): changes and containment" \
            "On $(target): $body, and the containment check did not pass.$(details_hint)" || true
        rc=1
    elif [ "$held" = 1 ]; then
        say "HELD: changes on $(target), inside the expected-changes window until $(clock "$wend")."
    elif [ "$newp" -gt 0 ]; then
        alert max "$(target_short): new listener or unit" \
            "On $(target): $body. $newp of them is something newly arranged to run or to be reached.$(details_hint)" || true
        rc=1
    elif [ "$changed" = 1 ]; then
        alert high "$(target_short): unexplained changes" \
            "On $(target), since the last accepted state: $body.$(known_hosts_note "$body")$(details_hint)" || 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

    local result=clean
    [ "$rc" -eq 0 ] || result=alerted
    [ "$held" = 0 ] || result="held, expected-changes window open until $(clock "$wend")"
    {
        printf '%s\n' "--- collected $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
        printf 'result: %s\n' "$result"
        printf 'reach exit: %s\n%s\n\n' "$reach_rc" "$reach_out"
        if [ -s "$now.accepted" ]; then
            printf 'accepted as the expected-changes window closed:\n'
            changes_table "$now.accepted"; printf '\n'
        fi
        if [ "$changed" = 1 ]; then
            printf 'changes against the baseline:\n'; changes_table "$now.diff"
            printf '\nas collected:\n'; cat "$now.diff"
        else
            printf 'no changes against the baseline\n'
        fi
        if [ -s "$now.ignored" ]; then
            printf '\nignored (ignore_paths):\n'; changes_table "$now.ignored"
        fi
    } > "$LAST"

    # Kept only when there was something to keep, so it is not overwritten by
    # the next quiet cycle. This is the copy an alert is telling you to read,
    # and a closing window's is the record of what it let through.
    if [ "$rc" -ne 0 ] || [ -s "$now.accepted" ]; then cp "$LAST" "$FINDING"; fi

    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
        [ "$held" = 1 ] || 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 raw err crc=0
    require_config
    install -d -m 0750 "$STATE_DIR"
    raw=$(mktemp) || die "no temp space"
    err=$(mktemp) || die "no temp space"
    # shellcheck disable=SC2064
    trap "rm -f '$raw' '$err'" EXIT

    # Through the same capped collector as a check. A baseline is the one file
    # here that everything else is compared against, so it is the last place to
    # accept an unbounded answer from the machine being baselined.
    collect "$raw" "$err" || crc=$?
    [ "$crc" -ne 1 ] || die "$(target) returned more than $(cfg max_state_bytes 8388608) bytes - not accepting that as a baseline"
    [ "$crc" -eq 0 ] || die "could not collect from the devbox"
    grep -q . "$raw" || die "the devbox returned nothing - not accepting that as a baseline"
    sanitise < "$raw" > "$BASE"
    rm -f "$raw" "$err" "$WINDOW"
    trap - EXIT
    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."

    # A check straight after, so the heartbeat resumes now rather than at the
    # next cycle, and the report reflects what was just accepted.
    say ""
    cmd_check
}

# Says changes are coming, so an update does not page anyone.
#
# Opened HERE, on the guard, and never from the devbox. A devbox that could say
# "expect changes" is a devbox that could silence its own guard.
#
# Until the window ends, changes and the devbox being unreachable are held
# rather than sent. Critical paths and containment still alert, since a change
# there is worth a push even during maintenance, and a window never accepts a
# critical path. At the end the rest of the devbox's state is accepted, by a
# check started now to run then rather than by whichever cycle of the timer
# comes next. If that check is lost, to a container restart say, the first
# check after the end does the same.
cmd_expect() {  # cmd_expect [DURATION|--cancel]
    local d=${1:-30m} n secs wend
    [ -f "$BASE" ] || die "no baseline yet - run $PROG accept first"
    if [ "$d" = --cancel ]; then
        rm -f "$WINDOW"
        say "Expected-changes window closed, nothing accepted. Changes will alert."
        return 0
    fi
    n=${d%[smh]}
    case "$n" in ''|*[!0-9]*) die "expect takes a duration like 30m, 2h or 90s - got '$d'" ;; esac
    case "$d" in
        *h) secs=$((n * 3600)) ;;
        *s) secs=$n ;;
        *)  secs=$((n * 60)) ;;
    esac
    [ "$secs" -le 86400 ] || die "refusing a window over 24h: that is not watching"
    wend=$(( $(date +%s) + secs ))
    printf '%s\n' "$wend" > "$WINDOW"

    # fd 9 is the lock, closed so the waiting check does not hold it while it
    # sleeps.
    setsid -f sh -c 'sleep "$1"; exec "$2" check' sh "$secs" "$0" \
        </dev/null >/dev/null 2>&1 9>&-

    say "Expecting changes on $(target) until $(clock "$wend")."
    say "  Changes and the devbox being unreachable are held until then. Critical"
    say "  paths and containment still alert. At $(clock "$wend") the state is accepted"
    say "  and a low-priority note says what changed."
    say ""
    say "  Done early:   $PROG accept"
    say "  Call it off:  $PROG expect --cancel"
}

cmd_status() {
    local wend
    if [ -f "$BASE" ]; then
        printf 'baseline:    accepted %s, %s entries\n' \
            "$(date -u -r "$BASE" +'%Y-%m-%dT%H:%M:%SZ')" "$(grep -c . "$BASE")"
    else
        printf 'baseline:    none - run %s accept\n' "$PROG"
    fi
    if [ -r "$LAST" ]; then
        printf 'last check:  %s, %s\n' "$(sed -n 's/^--- collected //p' "$LAST" | head -1)" \
            "$(sed -n 's/^result: //p' "$LAST" | head -1)"
    else
        printf 'last check:  none\n'
    fi
    wend=$(window_end)
    if [ -n "$wend" ]; then
        printf 'window:      expecting changes until %s\n' "$(clock "$wend")"
    else
        printf 'window:      none\n'
    fi
    if [ -r "$FINDING" ]; then
        printf 'last finding: %s (%s report --finding)\n' \
            "$(sed -n 's/^--- collected //p' "$FINDING" | head -1)" "$PROG"
    fi
}

# 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() {  # cmd_report [--finding]
    # AN ARGUMENT THIS COPY DOES NOT KNOW IS AN ERROR, not something to ignore.
    #
    # The version before --finding existed dropped every argument at the
    # dispatch, so `report --finding` printed the last report and looked like
    # the flag had worked. An operator comparing the two commands saw identical
    # output and reasonably concluded the feature did nothing, when what it did
    # was not exist yet in the copy they were running.
    #
    # A container makes that easy to be in: `git pull` changes the checkout and
    # not the image. So an unknown argument says which copy this is.
    case "${1:-}" in
        ""|--finding) ;;
        *) die "unknown argument for report: $1

This copy knows --finding and nothing else. If you expected a flag that is not
listed, the program answering you is older than the one you read about:

  docker compose up --build -d     # a container
  sudo ./install.sh                # a host install" ;;
    esac

    if [ "${1:-}" = "--finding" ]; then
        [ -r "$FINDING" ] || { say "no cycle has found anything yet"; return 0; }
        cat "$FINDING"
        return 0
    fi
    [ -r "$LAST" ] || { say "no report yet - run $PROG check"; return 0; }
    cat "$LAST"

    # Said when the last cycle was clean and an earlier one was not, because
    # that is exactly when an operator is looking and finding nothing. Compared
    # by content: a finding identical to the last report IS the last report.
    if [ -r "$FINDING" ] && ! cmp -s "$FINDING" "$LAST"; then
        say ""
        say "That was a clean cycle. The most recent one that found something is"
        say "still here, from $(sed -n 's/^--- collected //p' "$FINDING" | head -1):"
        say "  $PROG report --finding"
    fi
}

# 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|status|report [--finding]|accept|expect [30m]|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
  status      the baseline, the last check, and any expected-changes window
  accept      record the devbox's current state as the baseline, then check
  expect      hold alerts for changes and for the devbox being unreachable,
              then accept: 30m by default, or 2h, 90s. --cancel ends it
              without accepting
  report      the last cycle's detail, which is deliberately not in the alert.
              --finding shows the most recent cycle that found something, which
              a later clean cycle would otherwise have overwritten
  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
}

# One run at a time on the state directory. The loop, an operator's accept and
# the check a window left waiting would otherwise each read and write the
# baseline under the others.
lock() {
    install -d -m 0750 "$STATE_DIR"
    exec 9>"$STATE_DIR/lock"
    flock -w 300 9 || die "another $PROG run has held the lock for five minutes"
}

case "${1:-check}" in
    check)      lock; cmd_check ;;
    accept)     lock; cmd_accept ;;
    expect)     shift; lock; cmd_expect "$@" ;;
    status)     cmd_status ;;
    report)     shift; 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
