#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Brad Anderson
#
# The only entry point.
#
# There is no install.sh. The sister repository split bootstrap from install,
# and the split was later judged to serve nothing: it produced two commands
# that had to be run in the right order, and no host ever wanted one without
# the other. This repository does not repeat it.
#
# What this does NOT do is apply the firewall. That is on the hypervisor, by
# design, precisely so that a compromised guest cannot reach it - which means
# a program running in the guest cannot reach it either. proxmox/README.md is
# the half a person does, and this prints a reminder rather than pretending.

set -euo pipefail

PROG=${0##*/}
SRC="$(cd "$(dirname "$0")/.." && pwd)"

BIN_DIR=${BIN_DIR:-/usr/local/sbin}
CONF_DIR=${CONF_DIR:-/etc/dejima-devbox}
UNIT_DIR=${UNIT_DIR:-/etc/systemd/system}
SHARE_DIR=${SHARE_DIR:-/usr/local/share/dejima-devbox}

docker_group_added=0

# A first build or a re-run, decided before anything here writes state.
#
# It changes what the closing report should say, and getting it wrong is how a
# person mid-upgrade gets sent to section 3 of a runbook they finished months
# ago. Read from the state file rather than the config, because the config
# predates versioning and exists on hosts that were never bootstrapped by a
# version that wrote one.
rerun=0
[ -r "$CONF_DIR/state" ] && rerun=1

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

# The default is shown in brackets and taken on an empty answer, so a run with
# nothing typed reproduces the run before it.
ask() {
    local __var="$1" prompt="$2" def="$3" ans=""
    read -r -p "$prompt${def:+ [$def]}: " ans </dev/tty || true
    printf -v "$__var" '%s' "${ans:-$def}"
}

cfg_get() {
    [ -r "$CONF_DIR/config" ] || return 0
    sed -n "s/^$1=//p" "$CONF_DIR/config" | tail -1
}

writable() { mkdir -p "$1" 2>/dev/null || true; [ -d "$1" ] && [ -w "$1" ]; }

# ---------------------------------------------------------------------------
# 1. Prerequisites, before anything that would need the network to work.

for d in "$CONF_DIR" "$BIN_DIR" "$UNIT_DIR" "$SHARE_DIR"; do
    # Distinguish "cannot write" from "does not exist": [ -e ] cannot tell an
    # absent directory from an unsearchable one, and reporting the wrong one
    # sends people to reinstall something that is fine.
    writable "$d" || die "cannot write $d - run with sudo, or set CONF_DIR/BIN_DIR/UNIT_DIR/SHARE_DIR"
done

for c in ip timeout getent stat find; do
    command -v "$c" >/dev/null || die "$c is missing, and the audit needs it"
done
command -v systemctl >/dev/null || die "systemctl is missing - this expects a systemd host"

# Checked here rather than met at the point of use. apt-get failing halfway
# through leaves a host that is part configured, and runuser failing after that
# leaves one with Docker installed and no dejima on it.
for c in apt-get usermod runuser; do
    command -v "$c" >/dev/null || die "$c is missing - this expects a Debian host"
done

# ---------------------------------------------------------------------------
# 2. Whose devbox this is.
#
# dejima runs as a person and not as root: it mounts their project, writes
# their ~/.claude, and holds their credentials. This program runs as root, so
# the two have to be told apart before anything is installed for either of
# them. Guessing wrong puts the checkout in /root and the group on nobody.

target_user=${SUDO_USER:-}
if [ -z "$target_user" ] || [ "$target_user" = root ]; then
    say ""
    ask target_user "Which account will run dejima?" "$(cfg_get devbox_user)"
fi
[ -n "$target_user" ] || die "no account named, and dejima does not run as root"
# The || true is load-bearing under `set -e` with pipefail: getent exits
# non-zero on an unknown account, which would end the script here on the raw
# status rather than on the line below saying which name failed.
target_home=$(getent passwd "$target_user" | cut -d: -f6) || true
[ -n "$target_home" ] || die "no such account: $target_user"

# ---------------------------------------------------------------------------
# 3. What dejima needs from the host.
#
# Before the addressing step, and before anything is locked down, because this
# and the clone below are the two things here that need the network - and the
# addressing step is the one thing here that can take it away.
#
# docker.io from Debian rather than Docker's own repository: one less
# third-party apt source on a machine whose value is a small, known surface.
# What matters here is what the daemon is allowed to reach, which is decided
# on the hypervisor and not by a version number.

say ""
say "Installing what dejima needs..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# docker-cli named explicitly alongside docker.io. Debian splits the client
# from the daemon, and --no-install-recommends - otherwise right on a machine
# whose value is a small surface - drops it. apt then reports success, the
# daemon is installed, and there is no `docker` command at all.
#
# python3 and jq are dejima's own prerequisites: the host-side credential proxy
# is python3, and its installer reads settings.json with jq. git is here for
# the clone below.
#
# iputils-ping is the audit's, and it is named rather than assumed. A standard
# Debian install has it and a minimal or cloud image often does not, and its
# absence has no symptom: without it the audit cannot ask the segment who is
# reachable over IPv6, which is a check it must then report as INDETERMINATE
# rather than pass. Cheaper to install than to diagnose.
# ONLY WHAT IS MISSING, and that is a downtime decision rather than a tidiness
# one.
#
# `apt-get install` on an already-installed package is not a no-op: if the
# repositories offer a newer version it upgrades, and upgrading docker.io
# restarts the daemon and kills every running container with it. On a devbox
# that is somebody's session, mid-task, with no warning - and this program is
# documented as idempotent and safe to re-run, which is exactly when it would
# happen.
#
# Downtime on a devbox is a scheduled event. It is announced, work is drained
# first, and it does not arrive as a side effect of a step described as
# changing nothing. So this installs what is absent and touches nothing else;
# keeping packages current is `apt upgrade`, in a window somebody chose.
missing=""
for pkg in docker.io docker-cli python3 jq git ca-certificates curl iputils-ping; do
    dpkg-query -W -f='${db:Status-Abbrev}' "$pkg" 2>/dev/null | grep -q '^ii' || missing="$missing $pkg"
done
if [ -n "$missing" ]; then
    say "  installing:$missing"
    # shellcheck disable=SC2086
    apt-get install -y -qq --no-install-recommends $missing
else
    say "  packages   all present, nothing installed or upgraded"
fi

if ! command -v docker >/dev/null 2>&1; then
    {
        printf '%s: docker installed but there is no `docker` command.\n\n' "$PROG"
        printf 'What dpkg has:\n'
        dpkg -l 2>/dev/null | awk '/docker/ {printf "  %s %s %s\n", $1, $2, $3}'
        printf '\nDebian splits the client from the daemon. If docker.io is installed\n'
        printf 'and docker-cli is not, that is this - install it and re-run.\n'
    } >&2
    exit 1
fi
say "  docker     $(docker --version 2>&1 | head -1)"

# Compose, by whatever route this release actually offers.
#
# The package is docker-compose-v2 from trixie onward and does not exist on
# bookworm, so naming it in the list above aborts the whole bootstrap on Debian
# 12. Detecting and shrugging is not much better: dejima launches every session
# through `docker compose run`, so a devbox without compose is a devbox that
# cannot run a session. Try each route that exists, in order of how much it
# adds to the machine, and stop at the first that works.
have_compose() { docker compose version >/dev/null 2>&1; }
. /etc/os-release
codename="${VERSION_CODENAME:-}"

if have_compose; then
    say "  compose    already present"
else
    # 1. The distribution's own package, where it exists.
    if apt-cache show docker-compose-v2 2>/dev/null | grep -q '^Package:'; then
        say "  compose    installing docker-compose-v2 from $codename"
        apt-get install -y -qq --no-install-recommends docker-compose-v2 || true
    fi

    # 2. Backports, which is still Debian and still signed by Debian.
    if ! have_compose && [ -n "$codename" ]; then
        say "  compose    no distribution package on $codename; trying backports"
        printf 'deb http://deb.debian.org/debian %s-backports main\n' "$codename" \
            > /etc/apt/sources.list.d/backports.list
        apt-get update -qq || true
        apt-get install -y -qq --no-install-recommends \
            -t "$codename-backports" docker-compose-v2 || true
        if ! have_compose; then
            # Remove the source AND refresh, or apt keeps a list referring to
            # something that is no longer configured.
            rm -f /etc/apt/sources.list.d/backports.list
            apt-get update -qq || true
        fi
    fi

    # 3. Docker's own repository. Last because it is a third-party apt source
    #    on a machine whose value is a small, known surface - worth saying out
    #    loud rather than adding quietly.
    if ! have_compose; then
        say ""
        say "  compose    not available from Debian on $codename."
        say "             Adding Docker's official apt repository to get"
        say "             docker-compose-plugin. This is a third-party source on"
        say "             a host built to have few; the alternative is no"
        say "             compose, and no way to launch a session."
        say ""
        install -m 0755 -d /etc/apt/keyrings
        curl -fsSL https://download.docker.com/linux/debian/gpg \
            -o /etc/apt/keyrings/docker.asc || die "could not fetch Docker's signing key"
        chmod a+r /etc/apt/keyrings/docker.asc
        printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian %s stable\n' \
            "$(dpkg --print-architecture)" "$codename" \
            > /etc/apt/sources.list.d/docker.list
        apt-get update -qq
        apt-get install -y -qq --no-install-recommends docker-compose-plugin || true
    fi

    if ! have_compose; then
        {
            printf '%s: docker compose v2 is not usable.\n\n' "$PROG"
            printf 'What is on disk:\n'
            for d in /usr/libexec/docker/cli-plugins /usr/lib/docker/cli-plugins \
                     /usr/local/lib/docker/cli-plugins /usr/local/libexec/docker/cli-plugins; do
                [ -d "$d" ] && ls -1 "$d" 2>/dev/null | sed "s|^|  $d/|"
            done
            printf '  docker CLI: %s\n\n' "$(docker --version 2>&1 | head -1)"
            printf 'If a docker-compose binary is listed above then the package did\n'
            printf 'install, and the CLI searches these directories by default - so\n'
            printf 'report what you see rather than assuming a download failure. If\n'
            printf 'nothing is listed, no route produced one, which on a working network\n'
            printf 'usually means this host cannot reach download.docker.com.\n\n'
            printf 'Re-run once resolved; this script is idempotent and everything before\n'
            printf 'this point is already done.\n'
        } >&2
        exit 1
    fi
    say "  compose    $(docker compose version 2>/dev/null | head -1)"
fi

systemctl enable --now docker >/dev/null 2>&1 || true

# The docker group is root on this VM, in one command and with no exploit:
# `docker run -v /:/host --privileged`. Granted anyway, because dejima drives
# the daemon as this account and the alternative is a session that launches
# only under sudo. docs/threat-model.md treats it as a grant rather than a
# lapse, and the blast radius there is written assuming it.
if id -nG "$target_user" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then
    say "  group      $target_user is already in the docker group"
else
    usermod -aG docker "$target_user"
    say "  group      added $target_user to the docker group"
    docker_group_added=1
fi

# ---------------------------------------------------------------------------
# 4. dejima's source, while the network is still open.

# DEJIMA_DIR passed explicitly rather than left to $HOME. runuser resets HOME
# for the target user, but this program is also reachable under sudo and one
# `-m` or one preserved environment away from cloning into /root - which is a
# checkout the person who needs it cannot write to, in a place they will not
# look. State the path; it costs one word.
say ""
runuser -u "$target_user" -- env DEJIMA_DIR="$target_home/dejima" \
    "$SRC/bin/dejima-devbox-clone" \
    || die "dejima was not cloned - fix the above and re-run"

# ---------------------------------------------------------------------------
# 5. Addressing. Offered, not chosen for you.

say ""
say "Addressing."
say ""
say "  Static in the guest is the only mode needing no DHCP rules on the"
say "  hypervisor, so it keeps the rule set at four DROPs plus the exceptions."
say "  It costs maintenance: the address is then asserted in two places."
say ""
say "  An address reserved for you on the router is one source of truth and"
say "  survives a renumbering, but the guest still speaks DHCP and still needs"
say "  both halves of the DHCP pair in proxmox/README.md."
say ""
ask pin "Pin this host to the address it already has? (y/N)" "N"
case "$pin" in
    [yY]*)
        "$SRC/bin/dejima-devbox-static-ip" \
            || die "addressing was left unchanged - fix the above and re-run"
        ;;
    *)
        say ""
        say "  Left as it is. If this host uses DHCP, add rules 2a and 6a from"
        say "  proxmox/README.md. Both of them: the outbound one alone gets the"
        say "  request out and the lease still never renews."
        ;;
esac

# ---------------------------------------------------------------------------
# 6. Config. Two values, and one of them has to match a rule on the hypervisor.

say ""
say "The audit uses your forge as its positive control: if the forge answers,"
say "packets are moving, so silence from the gateway means the firewall rather"
say "than a broken network."
say ""
ask forge "Your forge's hostname" "$(cfg_get forge_host)"
[ -n "$forge" ] || die "the audit has no positive control without this"

case "$forge" in
    *[!a-zA-Z0-9.-]*|-*|.*)
        die "'$forge' is not a hostname - this wants a name, not a URL or an address" ;;
esac

if ! getent hosts "$forge" >/dev/null 2>&1; then
    say ""
    say "  WARNING: '$forge' does not resolve here."
    say "  Continuing, because the resolver rule may not be in place yet. If it"
    say "  still does not resolve after §3, the audit will report"
    say "  INDETERMINATE rather than guessing."
fi

# The account is recorded because the audit needs it too. Its radius report
# runs as root, from a timer or under sudo, and $HOME there is root's - so
# without this it would inventory an empty home and report no credentials on a
# box full of them.
umask 022
printf 'forge_host=%s\ndevbox_user=%s\n' "$forge" "$target_user" > "$CONF_DIR/config.new"
mv "$CONF_DIR/config.new" "$CONF_DIR/config"

# ---------------------------------------------------------------------------
# 7. Install the program and the units.
#
# The path below is written down in exactly one place. In the sister
# repository it was written in two, they disagreed, and a timer that had never
# worked on any host failed with 203/EXEC every time it fired without anyone
# noticing for months.

install -m 0755 "$SRC/bin/dejima-devbox-audit" "$BIN_DIR/dejima-devbox-audit"
[ -x "$BIN_DIR/dejima-devbox-audit" ] \
    || die "installed a file which is not executable: $BIN_DIR/dejima-devbox-audit"

install -m 0755 "$SRC/bin/dejima-devbox-upgrade" "$BIN_DIR/dejima-devbox-upgrade"

# The forced command for dejima-guard's key, installed whether or not a guard
# exists. It grants nothing on its own - nothing runs it until a key in
# authorized_keys names it - and having it present means the runbook step is
# one line in a file rather than "first, write this script".
install -m 0755 "$SRC/bin/dejima-guard-shell" "$BIN_DIR/dejima-guard-shell"

# Reachability, not just existence.
#
# Everything installs to /usr/local/sbin and every instruction in this
# repository reads `sudo dejima-devbox-...`. That works only where sudoers sets
# a secure_path including sbin; without one, sudo inherits the caller's PATH,
# and Debian leaves every sbin directory off a non-root PATH. The result is a
# correct install whose documented commands all report "command not found",
# which reads as a broken install rather than as a PATH.
#
# This runs under sudo, so its own PATH is the one those later commands will
# get - which makes `command -v` here the real question rather than a proxy.
if [ "$(command -v dejima-devbox-audit 2>/dev/null)" != "$BIN_DIR/dejima-devbox-audit" ]; then
    say ""
    say "NOTE: $BIN_DIR is not on the PATH sudo gives you here, so"
    say "  'sudo dejima-devbox-audit ...' will not be found. Either use the full"
    say "  path, as $BIN_DIR/dejima-devbox-audit, or add a secure_path to"
    say "  sudoers that includes it. Nothing about this install is wrong."
    say ""
fi

# The upgrade notes travel with the program, so an operator on a devbox with no
# checkout can still read what an upgrade asks of them. The installed copy
# knows only the versions that existed when it ran, and says so rather than
# reporting "up to date" about versions it has never heard of.
install -m 0755 -d "$SHARE_DIR/upgrades"
install -m 0644 "$SRC"/upgrades/*.md "$SHARE_DIR/upgrades/"

# What this host is now at, guest-side.
#
# Written from the checkout's VERSION rather than asked, because it records
# something this program just did rather than something a person remembers. The
# node half is not written here and cannot be: nothing in this guest can read
# the hypervisor's rules, which is the entire reason those rules are the
# primary control. dejima-devbox-upgrade mark-node is where that assertion goes.
version=$(tr -d '[:space:]' < "$SRC/VERSION")
[ -n "$version" ] || die "no version in $SRC/VERSION - refusing to record an empty one"
if [ -r "$CONF_DIR/state" ]; then
    grep -v '^guest_version=' "$CONF_DIR/state" > "$CONF_DIR/state.new" || true
else
    : > "$CONF_DIR/state.new"
fi
printf 'guest_version=%s\n' "$version" >> "$CONF_DIR/state.new"
sort -o "$CONF_DIR/state.new" "$CONF_DIR/state.new"
chmod 0640 "$CONF_DIR/state.new"
mv "$CONF_DIR/state.new" "$CONF_DIR/state"

for u in "$SRC"/systemd/*; do
    sed "s|@@BIN_DIR@@|$BIN_DIR|g" "$u" > "$UNIT_DIR/$(basename "$u")"
done

# Verify the substitution rather than trusting it. This is the check the
# sister repository did not have.
exe=$(sed -n 's|^ExecStart=\([^ ]*\).*|\1|p' "$UNIT_DIR/dejima-devbox-audit.service" | head -1)
[ -n "$exe" ] || die "the installed unit has no ExecStart - refusing to enable it"
[ -x "$exe" ] || die "unit names $exe, which is not executable - refusing to enable it"

systemctl daemon-reload
systemctl enable --now dejima-devbox-audit.timer

# ---------------------------------------------------------------------------
# 8. Run it once, so the first result is seen now rather than in a journal
#    nobody opens.

say ""
say "Checking containment."
say ""
# `reach` and not `all`. The closing lines below are about the hypervisor
# rules, and `all` also runs drift - which on a box with no baseline reports
# INDETERMINATE, makes this exit non-zero, and had the report announce a failed
# audit and send the operator to the firewall runbook while the firewall was
# fine. One question per verdict.
audit_rc=0
"$BIN_DIR/dejima-devbox-audit" reach || audit_rc=$?

# ---------------------------------------------------------------------------
# 9. Where this leaves the host.
#
# Three states, scanned in one block, with detail only for whichever of them is
# not the good answer.
#
# This used to be four paragraphs in a fixed order, every one addressed to
# somebody mid-first-build. An operator halfway through an upgrade was told to
# go and do section 3 of a runbook they finished months ago, underneath a PASS
# saying those rules were already on - and the audit it quoted had run `all`,
# so a drift check with no baseline reported INDETERMINATE and made the whole
# thing announce a failure that was not one.
#
# Exits 0 whatever the audit said. What this installs is in the guest and it
# either installed or died earlier; the audit's subject is the hypervisor,
# which on the documented order is configured after this runs.

case $audit_rc in
    0) reach_state="PASS"    ; reach_why="the hypervisor rules are in force" ;;
    2) reach_state="UNKNOWN" ; reach_why="nothing answered, including things that should" ;;
    *) reach_state="FAIL"    ; reach_why="something on your network answered" ;;
esac

# WHICH harness is dejima's business, not this program's. It supports more than
# one and that set changes there, so this table is recognition rather than a
# statement of what is supported: a harness missing from it is reported as
# something this cannot see, never as something the operator failed to do.
#
# Each row is <state directory>:<file that means signed in>:<name to say>,
# relative to the account's home. One row today, and a loop anyway: the next
# harness is a line rather than a rewrite.
harness_dir=""
# shellcheck disable=SC2043
for row in ".claude:.credentials.json:Claude Code"; do
    d="${row%%:*}"; rest="${row#*:}"
    if [ -d "$target_home/$d" ]; then
        harness_dir="$d"; harness_cred="${rest%%:*}"; harness_name="${rest#*:}"
        break
    fi
done

if [ -z "$harness_dir" ]; then
    harness_state="UNKNOWN"; harness_why="no harness recognised in $target_home"
elif [ ! -f "$target_home/$harness_dir/$harness_cred" ]; then
    harness_state="MISSING"; harness_why="$harness_name is not signed in for $target_user"
else
    harness_state="OK"; harness_why="$harness_name is signed in for $target_user"
fi

if [ -x "$target_home/.local/bin/dejima" ]; then
    dejima_state="OK"; dejima_why="installed for $target_user"
else
    dejima_state="TODO"; dejima_why="cloned to $target_home/dejima, not installed"
fi

say ""
say "=============================================================="
if [ "$rerun" = 1 ]; then
    say " Bootstrap re-run. The guest side is at v$version."
else
    say " Bootstrap complete. The guest side is at v$version."
fi
say "=============================================================="
say ""
printf '  %-12s %-8s %s\n' containment "$reach_state" "$reach_why" >&2
printf '  %-12s %-8s %s\n' harness     "$harness_state" "$harness_why" >&2
printf '  %-12s %-8s %s\n' dejima      "$dejima_state" "$dejima_why" >&2
say ""

# The one thing to do next, which depends on where the operator actually is.
#
# A re-run is almost always an upgrade, and an upgrade note is a procedure the
# operator is partway through. Sending them to the build runbook there is worse
# than saying nothing: it is confidently wrong about their position.
if [ "$rerun" = 1 ]; then
    say "  NEXT   You are re-running bootstrap, so you are most likely partway"
    say "         through an upgrade. Go back to the note you were following."
    say "         What is outstanding, if you have lost your place:"
    say ""
    say "           cd $SRC && ./bin/dejima-devbox-upgrade status"
elif [ "$reach_state" != PASS ]; then
    say "  NEXT   Apply the hypervisor firewall: proxmox/README.md section 3."
    say "         On the documented order it has not been done yet, so this"
    say "         reading is expected rather than a fault. Then:"
    say ""
    say "           $BIN_DIR/dejima-devbox-audit reach"
elif [ "$dejima_state" != OK ]; then
    say "  NEXT   Install dejima, as $target_user rather than as root:"
    say ""
    say "           cd $target_home/dejima && ./install.sh"
else
    say "  NEXT   Nothing. The timer re-checks containment hourly."
fi

# --- detail, only for what is not the good answer ---------------------------

if [ "$reach_state" != PASS ]; then
    say ""
    say "  containment: the rules are applied to this VM's tap device, on the"
    say "  node. Nothing in the guest can apply them and this program cannot"
    say "  either, so this reports what it found rather than fixing it."
fi

if [ "$harness_state" = UNKNOWN ]; then
    say ""
    say "  harness: dejima needs one installed for $target_user, run once so"
    say "  its state directory exists to be mounted, and signed in - the"
    say "  credential proxy runs out here and reads that account's copy. This"
    say "  recognises a set that is dejima's to change, so a harness it does"
    say "  not name is something it cannot see, not something you have missed."
elif [ "$harness_state" = MISSING ]; then
    say ""
    say "  harness: there is no $target_home/$harness_dir/$harness_cred. The"
    say "  credential proxy reads that file and hands the container a"
    say "  placeholder, so until it exists a session fails at the backend and"
    say "  the container is what looks broken. A key in that account's"
    say "  environment answers this too, and cannot be seen from root."
fi

if [ "$dejima_state" != OK ]; then
    say ""
    say "  dejima: install.sh writes into $target_user's account - the CLIs"
    say "  into ~/.local/bin, the hooks into ~/.claude/hooks, and a starter"
    say "  ~/.config/dejima/config. Put ~/.local/bin on PATH. A fresh config"
    say "  pushes nowhere until you choose a forge."
fi

if [ "$docker_group_added" = 1 ]; then
    say ""
    say "  docker: log out and back in before using it. $target_user was added"
    say "  to the docker group just now, and a shell already open does not have"
    say "  it - which reads as docker being broken rather than as a group that"
    say "  has not taken effect."
fi

say ""
say "  docs/building-a-devbox.md section 5 has the harness and dejima halves,"
say "  and what each one breaks when it is the missing one."
say ""
