#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Brad Anderson
#
# Verifies, from inside the VM, that the hypervisor's firewall is applied.
#
# It cannot read those rules. They live on the node, applied to this VM's tap
# device, which is exactly what makes them worth having and exactly what makes
# them unverifiable by inspection from here. Assuming they are in force is the
# failure this whole family of tools exists to prevent, so this tests behaviour
# instead.
#
# The target is the default gateway, and that choice does three jobs at once:
#
#   - its address is already in the routing table, so naming it writes down
#     nothing an attacker on this box could not already read;
#   - it is guaranteed to be alive, which no arbitrary address is;
#   - it sits inside the ranges the firewall drops, so with the rules applied
#     it must be unreachable as a destination, while traffic *through* it to
#     the internet is untouched.
#
# Nothing here is persisted. A file listing what this VM must not reach would
# be a map of valuable targets kept on the machine most likely to be owned, and
# would describe topology not otherwise obtainable from here without loud
# scanning. There is no such file and there should never be one.

set -euo pipefail

PROG=${0##*/}
CONF_DIR=${CONF_DIR:-/etc/dejima-devbox}

# What lateral movement actually uses, and what a gateway is most likely to
# answer on. Not 53: the resolver exception may legitimately allow it, so a
# rule working exactly as designed would read as a failure.
PROBE_PORTS=(443 80 22)
PROBE_TIMEOUT=${PROBE_TIMEOUT:-3}

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

default_gateway() {
    local gw
    gw=$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')
    [ -n "$gw" ] || return 1
    printf '%s\n' "$gw"
}

# open     - handshake completed. Definitely reachable.
# answered - refused, or unreachable. Something replied, so not silently
#            dropped, which is the only thing that counts as contained.
# timeout  - nothing came back.
#
# The open/answered split does not change the verdict; both are definite. It is
# kept because it is free and it tells the operator what they are looking at.
#
# KNOWN LIMIT: bash's /dev/tcp cannot tell ECONNREFUSED from EHOSTUNREACH, so a
# firewall that REJECTs rather than DROPs looks the same as a live host that
# refused. Both land in `answered` and both produce FAIL. That is the safe
# direction to be wrong in - it fails closed, and it is loud - but it means the
# rules must use DROP for a correct PASS. proxmox/firewall.fw.template specifies
# DROP, and this is one of the reasons.
probe_tcp() {
    local host=$1 port=$2 rc=0
    timeout "$PROBE_TIMEOUT" bash -c "exec 3<>/dev/tcp/$host/$port" 2>/dev/null || rc=$?
    case $rc in
        0)   printf 'open\n' ;;
        124) printf 'timeout\n' ;;
        *)   printf 'answered\n' ;;
    esac
}

# FAIL | PASS | INDETERMINATE
#
# Three states rather than two, because a timeout has two causes and reading it
# as a pass is a false negative. False negatives are the wrong direction for a
# security check: the operator stops looking.
gateway_verdict() {
    local gw port result
    if ! gw=$(default_gateway); then
        # A failure rather than a skip: this check carries the exit status on
        # its own, and a skip would leave a green run that verified nothing.
        say "$PROG: no default route, so there is nothing to probe."
        say "  Fix the route and run again."
        return 2
    fi
    for port in "${PROBE_PORTS[@]}"; do
        result=$(probe_tcp "$gw" "$port")
        case $result in
            open|answered) printf 'FAIL\n'; return 0 ;;
        esac
    done
    if positive_controls_ok; then
        printf 'PASS\n'
    else
        printf 'INDETERMINATE\n'
    fi
}

cfg_get() {
    local f="$CONF_DIR/config" v=""
    [ -r "$f" ] && v=$(sed -n "s/^$1=//p" "$f" | tail -1)
    [ -n "$v" ] || return 1
    printf '%s\n' "$v"
}

# The forge's name, from config. Nothing is derived from the network here on
# purpose: an attacker standing on this box can read /etc/dejima-devbox/config
# as easily as we can, so there is no secret to protect and no reason to be
# clever. What matters is that it is one value in one place, matching the rule
# on the hypervisor - see proxmox/README.md, "Values that must agree".
forge_host() { cfg_get forge_host; }

# Whose home the radius report inventories, which is not this process's.
#
# This runs as root - from the timer, or under sudo, where HOME is root's - and
# root's home on a devbox holds nothing. Everything the report is about lives in
# the account that runs dejima: its credentials, its session memory, its
# checkouts. Inventorying the wrong one does not merely miss them, it reports
# "none found" on a box full of them, which is the reassuring direction to be
# wrong in.
#
# bootstrap records the account. Falls back to $HOME when it is absent rather
# than guessing at /home/*, so a host set up before this existed reports exactly
# what it always did.
devbox_home() {
    local user home=""
    if user=$(cfg_get devbox_user); then
        home=$(getent passwd "$user" 2>/dev/null | cut -d: -f6)
    fi
    printf '%s\n' "${home:-$HOME}"
}

resolve_ok() { getent hosts "$1" >/dev/null 2>&1; }

# --- addressing -------------------------------------------------------------
#
# Which mode this host uses decides whether it needs the two DHCP rules on the
# hypervisor, and that question has the nastiest failure in the whole design.
# A DHCP guest behind these rules renews through a path the firewall may be
# dropping. Nothing breaks at boot, because the first lease was obtained before
# the rules mattered. The VM simply stops renewing and falls off the network up
# to a full lease time later, looking nothing like a firewall rule.

INTERFACES_FILE=${INTERFACES_FILE:-/etc/network/interfaces}
LEASE_GLOB=${LEASE_GLOB:-/var/lib/dhcp/dhclient*.leases}

lease_file() {
    local f
    # shellcheck disable=SC2086
    f=$(ls -t $LEASE_GLOB 2>/dev/null | head -1)
    [ -n "$f" ] || return 1
    printf '%s\n' "$f"
}

# static | dhcp | unknown
#
# Static is checked first, and that order is the whole correctness of this
# function. A host pinned by dejima-devbox-static-ip keeps the lease files from
# whatever it was doing before, so letting a lease decide would tell every
# pinned host it needs rules it does not need.
addressing_mode() {
    if [ -r "$INTERFACES_FILE" ] \
       && grep -qE '^[[:space:]]*iface[[:space:]]+\S+[[:space:]]+inet[[:space:]]+static' "$INTERFACES_FILE"; then
        printf 'static\n'; return 0
    fi
    if { [ -r "$INTERFACES_FILE" ] \
         && grep -qE '^[[:space:]]*iface[[:space:]]+\S+[[:space:]]+inet[[:space:]]+dhcp' "$INTERFACES_FILE"; } \
       || lease_file >/dev/null; then
        printf 'dhcp\n'; return 0
    fi
    # Not guessed. An unrecognised setup is a thing to look at, not a thing to
    # assume the friendlier answer about.
    printf 'unknown\n'
}

# dhclient writes "renew 3 2026/08/10 04:12:33;" in UTC. Prints epoch seconds.
lease_time() {  # lease_time FILE KEY
    local v
    v=$(grep -E "^[[:space:]]*$2 " "$1" 2>/dev/null | tail -1 \
        | sed -E "s/^[[:space:]]*$2 [0-9] //; s/;[[:space:]]*$//") || return 1
    [ -n "$v" ] || return 1
    date -u -d "$v" +%s 2>/dev/null || return 1
}

# Does the network demonstrably work?
#
# This is the whole reason a timeout to the gateway can mean anything. If the
# forge resolves or answers, packets are moving and the tap is up, so silence
# from the gateway is a filter. Without that, silence is just silence.
#
# Either control alone is sufficient, and they fail differently on purpose:
# resolution exercises the resolver exception, a handshake exercises layer 4
# and the forge exception. Requiring both would turn an unrelated outage into
# a report the operator learns to ignore.
positive_controls_ok() {
    local host
    host=$(forge_host) || return 1
    resolve_ok "$host" && return 0
    [ "$(probe_tcp "$host" 443)" = open ] && return 0
    return 1
}

cmd_reach() {
    local verdict rc=0
    verdict=$(gateway_verdict) || return $?
    case $verdict in
        PASS)
            say "PASS: the gateway is unreachable and the forge is reachable."
            ;;
        FAIL)
            say "FAIL: the gateway answered."
            say "  Check, in this order:"
            say "    1. the Firewall checkbox on the VM's Network device"
            say "    2. rule order in the security group: every exception must sit"
            say "       above the DROPs. The console inserts new rules at the top,"
            say "       so a rule left where it landed is below nothing."
            say "    3. that the rules DROP rather than REJECT. A REJECT is working"
            say "       and still answers, which this probe reads as reachable."
            rc=1
            ;;
        INDETERMINATE)
            say "INDETERMINATE: nothing answered, including the forge."
            say "  A working firewall and a broken network look the same from here."
            say "  Fix connectivity and run again. This is not a pass."
            rc=1
            ;;
    esac
    printf '%s\n' "$verdict"
    return $rc
}

# Is the lease actually being renewed?
#
# Detected without keeping any state between runs, which matters: the obvious
# design is to record the expiry each run and see whether it advances, and that
# means this program starts writing files. It does not need to. dhclient
# rewrites the lease file on every successful renewal, so the file's own mtime
# against the renewal time it records answers the question in one pass.
#
# Past T1 with an mtime older than T1 means dhclient asked and got nothing
# back. That is precisely the shape of a dropped reply, and it is visible hours
# before the lease expires and the VM disappears.
lease_report() {
    local f now renew expire
    if ! f=$(lease_file); then
        say "  no lease file found, so renewal is not checked"
        return 0
    fi
    now=$(date -u +%s)
    renew=$(lease_time "$f" renew) || renew=""
    expire=$(lease_time "$f" expire) || expire=""

    if [ -n "$expire" ]; then
        say "  lease expires in $(( (expire - now) / 3600 ))h"
    fi
    if [ -z "$renew" ]; then
        say "  no renewal time in $f, so renewal is not checked"
        return 0
    fi

    if [ "$now" -gt "$renew" ] && [ "$(stat -c %Y "$f" 2>/dev/null || echo 0)" -lt "$renew" ]; then
        say "  NOT RENEWING. The renewal time has passed and dhclient has not"
        say "  rewritten $f, which is what a dropped reply looks like."
        say "  Check that BOTH DHCP rules are on the hypervisor: the outbound one"
        say "  alone gets the request out and the lease still never completes."
    fi
    return 0
}

# The newest kernel dpkg holds installed, in the form uname -r prints. Empty
# if the question cannot be answered here.
#
# Two transformations, and leaving either out makes the answer wrong in the
# alarming direction. Debian ships the signed image as linux-image-<version>
# and the unsigned one as linux-image-<version>-unsigned, while uname prints
# neither suffix, so a bare package name compares unequal on every host running
# an unsigned image and reports a reboot that is not pending. And dpkg-query
# reports packages it merely remembers, so a kernel removed but not purged
# would otherwise present itself as installed.
newest_kernel() {
    command -v dpkg-query >/dev/null 2>&1 || return 0
    # The || true is load-bearing. dpkg-query exits non-zero when nothing
    # matches, and under set -e with pipefail that killed cmd_radius halfway
    # through its own report - which looked like the report simply ending, with
    # no error anywhere. Found by running it in a container.
    dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' 'linux-image-[0-9]*' 2>/dev/null \
        | sed -n 's/^ii  *linux-image-//p' \
        | sed 's/-unsigned$//' \
        | sort -V | tail -1 || true
}

# What root on this VM would obtain.
#
# This looks like the target list the audit refuses to keep, and it is not. The
# difference is where the information lives. A list of other hosts describes the
# network beyond this VM, which is not obtainable from here without scanning
# loud enough to notice. An inventory of this box describes a box the attacker
# is already standing on, and reveals nothing a single find would not.
#
# Paths and modes. Never contents.
cmd_radius() {
    local f found=0 newest running mode home
    home=$(devbox_home)

    say ""
    say "== What root on this VM would get =="

    say ""
    say "Credentials in $home:"
    for f in "$home"/.ssh/id_* "$home"/.aws/credentials "$home"/.config/gh/hosts.yml \
             "$home"/.docker/config.json "$CONF_DIR"/*token*; do
        case "$f" in *.pub) continue ;; esac
        [ -f "$f" ] || continue
        found=1
        say "  $(stat -c '%A %U:%G' "$f" 2>/dev/null)  $f"
    done
    if [ "$found" = 1 ]; then
        say "  Each of these is reusable off this box. Revoke and reissue them if"
        say "  this host is ever suspect."
    else
        say "  none found"
    fi

    # Listed separately because the advice differs. Everything above can be
    # taken off the box; this cannot, so knowing it is here is the whole of
    # what an operator can do about it.
    say ""
    say "Model API token:"
    if [ -f "$home/.claude/.credentials.json" ]; then
        say "  $(stat -c '%A %U:%G' "$home/.claude/.credentials.json" 2>/dev/null)  $home/.claude/.credentials.json"
        say "  dejima keeps this out of the container. The proxy holding it runs"
        say "  here, so root here has it, and it cannot be moved off this host."
        say "  Rotate it first if this host is ever suspect."
    else
        say "  not present. This host either launches no sessions, or"
        say "  authenticates with an API key held elsewhere."
    fi

    say ""
    say "Group membership:"
    if id 2>/dev/null | grep -q '(docker)'; then
        say "  in the docker group, which is root-equivalent: 'docker run -v /:/host"
        say "  --privileged' needs no exploit."
        say "  Required by dejima. Reported, not corrected - docs/threat-model.md."
    else
        say "  not in the docker group"
    fi

    say ""
    say "Agent memory:"
    if [ -d "$home/.claude/projects" ]; then
        say "  $(find "$home/.claude/projects" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l) project directories under $home/.claude/projects, readable by root here"
    else
        say "  no $home/.claude/projects on this host"
    fi

    say ""
    say "Addressing:"
    mode=$(addressing_mode)
    case $mode in
        static)
            say "  static. Needs no DHCP rules on the hypervisor."
            ;;
        dhcp)
            say "  DHCP. The hypervisor needs BOTH halves of the DHCP pair, out"
            say "  udp/67 and in udp/68, including when the address is reserved for"
            say "  you on the router: a reservation does not stop the guest"
            say "  speaking DHCP."
            lease_report
            ;;
        *)
            say "  could not tell: no static or dhcp stanza in $INTERFACES_FILE and"
            say "  no lease file, so whether the DHCP rules are needed is unresolved"
            ;;
    esac

    say ""
    say "Kernel:"
    running=$(uname -r)
    newest=$(newest_kernel)
    say "  running:          $running"
    if [ -n "$newest" ]; then
        say "  newest installed: $newest"
        if [ "$newest" = "$running" ]; then
            say "  reboot pending:   no"
        else
            say "  reboot pending:   YES, so the newest kernel is not the one running"
        fi
    else
        # Blank would read as a bug. Say which question went unanswered.
        say "  newest installed: could not tell, no packaged kernel found."
        say "  Expected in a container or on an image-based system; on a VM it"
        say "  usually means this check is wrong rather than that no kernel is"
        say "  installed."
    fi
    return 0
}

# This program describes the machine it runs on. Run inside a container it
# describes the container, and every reading is real: the gateway is the
# bridge, the credential is whatever is mounted here, the group memberships
# are the image's. None of it is about the devbox, and nothing in the output
# would say so.
#
# DETECTED rather than declared, which is the opposite of test/devbox.bats, and
# the difference is the direction each one fails in. A test that mis-detects
# skips and reports nothing, so it takes a declared variable. This mis-detects
# and prints one extra paragraph. Loud and wrong is cheap; quiet and wrong is
# the thing worth avoiding.
in_container() {
    [ -f /.dockerenv ] && return 0
    [ -f /run/.containerenv ] && return 0
    grep -qaE 'docker|libpod|containerd' /proc/1/cgroup 2>/dev/null && return 0
    return 1
}

# Warn, do not refuse. Someone reading the program's output while developing it
# has a good reason to be here, and refusing would only teach them to work
# around the check.
container_warning() {
    in_container || return 0
    say "$PROG: this looks like a container, not a devbox."
    say "  What follows describes the container: its gateway, its mounts, its"
    say "  group memberships. For a reading about the devbox, install this with"
    say "  dejima-devbox-bootstrap and run it there. Continuing anyway."
    say ""
}

main() {
    container_warning
    case "${1:-all}" in
        reach)  cmd_reach ;;
        radius) cmd_radius ;;
        all)    cmd_radius; cmd_reach ;;
        *)      say "usage: $PROG [reach|radius]"; return 64 ;;
    esac
}

# Run only when executed, so test/audit.bats can source this and replace the
# two functions that touch the outside world. The :- guard matters under
# set -u: a shell without BASH_SOURCE would otherwise abort here rather than
# fall through to doing nothing.
if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
    main "$@"
fi
