#!/usr/bin/env bash
# slag-kexec.sh - run ON the machine to retire (as root). It replaces the
# running OS with the signed slag appliance, in RAM, with SSH on, so the
# machine can be retired remotely: every disk, the root disk included, is
# free to be wiped, because nothing of the old OS keeps running.
#
#   slag-kexec.sh --dry-run                  fetch + verify + show the plan
#   slag-kexec.sh --yes-kexec "$(hostname)"  ...then kexec (the OS stops here)
#
# It does NOT wipe anything. After the kexec the appliance comes up with the
# same IPv4/IPv6 addresses (passed on the kernel command line: many VPSes have
# no DHCP) and dropbear accepting only the key baked into the image. Wipe from
# there (retire-vps.sh --wipe, or ssh in and run slag / slag.sh).
#
# Trust: the build is fetched over HTTPS pinned to the slag netboot root (the
# CA below, nothing else) and every file's detached CMS signature must verify
# against that root AND name the code-signing cert as signer. Unlike the iPXE
# path, this check runs inside the old OS: if that OS is already compromised
# it could kexec anything. That is the price of not needing the console.
#
# Options:
#   --handoff FILE   an uncompressed cpio to load BEFORE the signed initramfs
#                    (retire-vps.sh puts the appliance's pre-generated SSH host
#                    key in it, so the first connection is pinned). The signed
#                    archive is unpacked after it, so it cannot replace a
#                    single file of the image.
#   --ssh-port N     the appliance's SSH port (default 22). On a public VPS
#                    port 22 is under constant brute force, which fills
#                    dropbear's unauthenticated slots and
#                    locks the operator out. Behind a provider firewall
#                    keep the port it allows (22) and use --ssh-from.
#   --ssh-from LIST  only these source addresses (comma-separated IPv4/IPv6,
#                    optionally /prefix) reach the appliance's SSH; nftables
#                    drops the rest (retire-vps.sh passes the operator's).
#   --base URL       where boot.ipxe lives (default https://pxe.slag.sh)
#   --ca FILE        trust root to pin instead of the embedded one (lab)
#   --dry-run        everything except loading and executing the kernel
#
# Missing tools are installed first, with the system's package manager
# (apt-get, dnf, yum, zypper or apk): curl, openssl and ip to fetch and check
# the build (also on --dry-run, which cannot check anything without them),
# and kexec-tools (only on the real run).
#   --yes-kexec NAME required to kexec; must equal this machine's hostname
set -euo pipefail
umask 077

BASE="https://pxe.slag.sh"
SIGNER_CN="itys-burn-netboot-codesign"
HANDOFF="" CA_FILE="" DRY=0 YES="" SSH_PORT=22 SSH_FROM=""
die() { printf 'slag-kexec: %s\n' "$*" >&2; exit 1; }
say() { printf 'slag-kexec: %s\n' "$*"; }

while [[ $# -gt 0 ]]; do
  case $1 in
    --handoff) HANDOFF=${2:?}; shift 2 ;;
    --base) BASE=${2:?}; shift 2 ;;
    --ca) CA_FILE=${2:?}; shift 2 ;;
    --ssh-port) SSH_PORT=${2:?}; shift 2 ;;
    --ssh-from) SSH_FROM=${2:?}; shift 2 ;;
    --dry-run) DRY=1; shift ;;
    --yes-kexec) YES=${2:?}; shift 2 ;;
    -h|--help) sed -n '2,43p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
done
[[ $DRY -eq 1 || -n $YES ]] || die "give --dry-run, or --yes-kexec $(hostname) to replace this OS with the slag appliance"
[[ -z $YES || $YES == "$(hostname)" ]] || die "--yes-kexec '$YES' is not this machine's hostname ($(hostname)); nothing done"
[[ $SSH_PORT =~ ^[0-9]{1,5}$ ]] && (( SSH_PORT >= 1 && SSH_PORT <= 65535 )) || die "--ssh-port: not a port: $SSH_PORT"
[[ -z $SSH_FROM || $SSH_FROM =~ ^[0-9A-Fa-f:./,]+$ ]] || die "--ssh-from: not a list of addresses: $SSH_FROM"
[[ $(id -u) -eq 0 ]] || die "must run as root"
[[ $(uname -m) == x86_64 ]] || die "the slag appliance is x86_64 only (this is $(uname -m))"

# Kernel lockdown (Secure Boot) refuses an unsigned kernel; say so up front.
if [[ -r /sys/kernel/security/lockdown ]] && grep -q '\[integrity\]\|\[confidentiality\]' /sys/kernel/security/lockdown; then
  die "kernel lockdown is on ($(cat /sys/kernel/security/lockdown)): kexec of the slag kernel will be refused. Use the provider's rescue mode (slag.py --provider) or the slag stick instead"
fi

# pkg_name TOOL MANAGER: the package that provides TOOL.
pkg_name() {
  case "$1:$2" in
    ip:apt|ip:apk)          echo iproute2 ;;
    ip:dnf|ip:yum|ip:zypper) echo iproute ;;
    kexec:*)                echo kexec-tools ;;
    *)                      echo "$1" ;;
  esac
}

# need_tools TOOL...: install the packages for every TOOL not on PATH.
need_tools() {
  local t m="" pkgs=() missing=()
  for t in "$@"; do command -v "$t" >/dev/null || missing+=("$t"); done
  [[ ${#missing[@]} -gt 0 ]] || return 0
  if command -v apt-get >/dev/null; then m=apt
  elif command -v dnf >/dev/null; then m=dnf
  elif command -v yum >/dev/null; then m=yum
  elif command -v zypper >/dev/null; then m=zypper
  elif command -v apk >/dev/null; then m=apk
  else die "missing ${missing[*]} and no package manager found (apt-get, dnf, yum, zypper, apk); install them first"; fi
  for t in "${missing[@]}"; do pkgs+=("$(pkg_name "$t" "$m")"); done
  say "installing ${pkgs[*]} (missing: ${missing[*]}) with $m"
  case $m in
    apt)
      # Debian asks whether kexec should handle reboots; it must not.
      echo 'kexec-tools kexec-tools/load_kexec boolean false' | debconf-set-selections 2>/dev/null || true
      # Fresh cloud images ship with empty package lists.
      DEBIAN_FRONTEND=noninteractive timeout 300 apt-get update -q >/dev/null 2>&1 || true
      DEBIAN_FRONTEND=noninteractive timeout 300 apt-get install -y -q "${pkgs[@]}" >/dev/null ;;
    dnf)    timeout 300 dnf install -y -q "${pkgs[@]}" >/dev/null ;;
    yum)    timeout 300 yum install -y -q "${pkgs[@]}" >/dev/null ;;
    zypper) timeout 300 zypper --non-interactive --quiet install "${pkgs[@]}" >/dev/null ;;
    apk)    timeout 300 apk add -q "${pkgs[@]}" >/dev/null ;;
  esac || die "$m could not install ${pkgs[*]}"
  for t in "${missing[@]}"; do command -v "$t" >/dev/null || die "$t is still missing after installing $(pkg_name "$t" "$m")"; done
}

# Fetch and check need these, so --dry-run installs them too.
need_tools curl openssl ip
if ! command -v kexec >/dev/null; then
  if [[ $DRY -eq 1 ]]; then say "kexec-tools is not installed; the real run installs it"
  else need_tools kexec; fi
fi

# Scratch in RAM (the old disk is about to be wiped anyway, but nothing here
# should depend on it): kernel + initramfs are ~85 MB.
work=$(mktemp -d /dev/shm/slag-kexec.XXXXXX 2>/dev/null || mktemp -d /tmp/slag-kexec.XXXXXX)
trap 'rm -rf -- "$work"' EXIT
ca="$work/ca.crt"
if [[ -n $CA_FILE ]]; then cp "$CA_FILE" "$ca"; else cat > "$ca" <<'CA'
-----BEGIN CERTIFICATE-----
MIIFVDCCAzygAwIBAgIULYqjs1gEB5sYOONcBchdQQfn3T0wDQYJKoZIhvcNAQEL
BQAwMDENMAsGA1UECgwESVRZUzEfMB0GA1UEAwwWSVRZUyBidXJuIG5ldGJvb3Qg
cm9vdDAeFw0yNjEwMzExNTU5NDJaFw00NjEwMzExNTU5NDJaMDAxDTALBgNVBAoM
BElUWVMxHzAdBgNVBAMMFklUWVMgYnVybiBuZXRib290IHJvb3QwggIiMA0GCSqG
SIb3DQEBAQUAA4ICDwAwggIKAoICAQCyFbaQmIGiQI+z0EN1FFs6d6ZVhgdRIWfz
QqGSvbfuthWEhfEsAB6yJ2ODu7wUF/BqwgRjWE/ltgdGH9N7xcMvsHa6iJ0Pc0z5
syGBmKmaCEC8HrFGIa0LorbL92Vxqz710oYMBqwSMBPP1IIEOd29ZyBJvjuxBod5
l+NVP5Que37hX8BpNO9lCoS2K8NNSvafBMPSPbhMJdGbAvAffLtcOLA96XVSrNO+
dSBzBJLab6OslNanXheh4+8ylf30QqOWFVn0svQH8Fa5Jwxw6lvIZE8UDzWAf/e2
edEL4lZJmNlpnwBCjRgWVfcxYyhTxcR/0b4FU6vq67XluLh12QSkO/Cqz4wAYMin
Sh7nfIMGUO2cEY/DBb3VICPL1QONYxfkf7w33zwK4jBEd/2Q9NyPp7jE0XPON+4N
S9HF5AangnSyl+CccWQs7efBvJQwXsiqOhiPK/M5cMsoefN3k5DzSLd8DJyTymUK
PeomywK/MidMYe/F5dvDBmoHBK4NMnrxSODrx9RAVo/osPAC3NTqguliRnhFmn49
hy1bYdKwuPrnE72R0wz4LaetgCWElUPY0gkVh+6BIJLraek5mXhX4yRB1Z/Ko5rU
CwMo2lbYZgF5K2iSmyaxkVXTx0TUctTAlomy0cBGhUUpuPkNtVsNXmrZTeZBhQOy
apZIj3gExwIDAQABo2YwZDAfBgNVHSMEGDAWgBTKkRZngpfTcOj33nU2N3EY8o4O
1zASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU
ypEWZ4KX03Do9951NjdxGPKODtcwDQYJKoZIhvcNAQELBQADggIBAHx7FMZMUP3L
dcXvjLOQ2+/8ReTInScmklmq854xj3clw883C6sP/umwIDzRDPg5RO65Opsz9ikJ
Ojw6FCvHHSphe2vM7Cjlk6znYzEWIDeYUwjX/P/gKlwcDVRr31iAR+uER3UXgOPI
vckjdbitKH57ZoxnRYoJ59lXUu+O00anOIKvgofcGTPNO2ucJk6suVB31gEupZfO
BAimZfCZxZI+5G2CsoNcWDsK5bn1EvM8/Ynjlb9GwnHVIKe7LSkW3QzO9kUh6n6/
8+ZTJbIpLvlkQOMq21rjexRuBf/009lgCQ2GtR8DYTMx8KQ04E3fExneW47DUpeR
R+ipIciAIq7tN3huSOoYOfXRM6A9aG8SY9wcU7XtXKaZ17qkzU8ZrZE9bIxbNyf4
GwtFjokTOQqlJzw2SF7fzzut7jUbawGhR7IBnSAIvUMIdl2hPImsH5khDNGbCfuK
oh0maTsRRRfhAe1NzJCCGsdanDwb+aMEeqeNc1YdH6udwxolniWiMIpaEK6U2g3L
TD/JZb4RuCkFNz9dJBG22gu7+3QpTMIbSDfpnXhF+tzuW8RmGmbvXOl33ca6J995
Zlg8nbijXhjT4ILbLLeb8jivlP45TRJCIi6oeyX9mAENEBK92jY6hZks9/FWTteW
bBnt9OhADAa/RpDHY3nfyaj8XIX8nEb7
-----END CERTIFICATE-----
CA
fi
openssl x509 -in "$ca" -noout 2>/dev/null || die "no usable trust root"
say "trust root: $(openssl x509 -in "$ca" -noout -subject -nameopt RFC2253 | sed 's/^subject=//'), SHA-256 $(openssl x509 -in "$ca" -noout -fingerprint -sha256 | sed 's/.*=//')"

get() {  # URL FILE
  curl -fsS --proto '=https' --tlsv1.2 --cacert "$ca" --retry 3 --connect-timeout 15 --max-time 300 -o "$2" "$1" \
    || die "download failed: $1"
}
verify() {  # FILE: detached CMS signature FILE.sig, root $ca, signer $SIGNER_CN
  openssl cms -verify -binary -inform DER -in "$1.sig" -content "$1" -CAfile "$ca" -purpose any \
    -certsout "$work/signer.pem" -out /dev/null 2>/dev/null || die "SIGNATURE CHECK FAILED: $(basename "$1")"
  openssl x509 -in "$work/signer.pem" -noout -subject -nameopt multiline | grep -qx "    commonName *= *$SIGNER_CN" \
    || die "$(basename "$1") is signed, but not by $SIGNER_CN; refusing"
}

# boot.ipxe names the build directory; fetching through it (rather than
# current/) keeps kernel and initramfs from two different builds apart.
get "$BASE/boot.ipxe" "$work/boot.ipxe"; get "$BASE/boot.ipxe.sig" "$work/boot.ipxe.sig"
verify "$work/boot.ipxe"
build=$(sed -n 's|^set base \(https://[^ ]*/slag/[A-Za-z0-9TZ-]*\)$|\1|p' "$work/boot.ipxe")
[[ -n $build ]] || die "cannot find the build in boot.ipxe"
say "build: ${build##*/}"
for f in vmlinuz initramfs; do
  get "$build/$f" "$work/$f"; get "$build/$f.sig" "$work/$f.sig"; verify "$work/$f"
done
say "vmlinuz and initramfs: signatures verified (signer $SIGNER_CN)"

# The network as it is now, for the appliance (no DHCP assumed).
args="console=tty0 console=ttyS0,115200 slag.ssh=1"
[[ $SSH_PORT -eq 22 ]] || args+=" slag.ssh.port=$SSH_PORT"
[[ -z $SSH_FROM ]] || args+=" slag.ssh.from=$SSH_FROM"
dev=$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev"){print $(i+1); exit}}')
[[ -n $dev ]] || dev=$(ip -6 route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev"){print $(i+1); exit}}')
[[ -n $dev ]] || die "no default route: the appliance would be unreachable"
mac=$(cat "/sys/class/net/$dev/address")
a4=$(ip -4 -o addr show dev "$dev" scope global | awk '{print $4; exit}')
g4=$(ip -4 route show default dev "$dev" | awk '{print $3; exit}')
a6=$(ip -6 -o addr show dev "$dev" scope global | awk '$0 !~ /temporary|deprecated/ {print $4; exit}')
g6=$(ip -6 route show default dev "$dev" | awk '{print $3; exit}')
args+=" slag.mac=$mac"
[[ -z $a4 ]] || args+=" slag.ip=$a4"
[[ -z $g4 ]] || args+=" slag.gw=$g4"
[[ -z $a6 ]] || args+=" slag.ip6=$a6"
[[ -z $g6 ]] || args+=" slag.gw6=$g6"
say "appliance network: $dev ($mac) ${a4:-no IPv4}${g4:+ via $g4} ${a6:-no IPv6}${g6:+ via $g6}"
say "kernel command line: $args"

initrd="$work/initramfs"
if [[ -n $HANDOFF ]]; then
  [[ -s $HANDOFF ]] || die "--handoff $HANDOFF is empty or missing"
  # Handoff first: the signed archive unpacks after it and wins every path.
  cat "$HANDOFF" "$work/initramfs" > "$work/initrd.combined"
  initrd="$work/initrd.combined"
  say "handoff: $(basename "$HANDOFF") ($(stat -c %s "$HANDOFF") bytes) before the signed initramfs"
fi

if [[ $DRY -eq 1 ]]; then
  say "dry run: would now load the slag kernel and kexec into it. Nothing changed."
  exit 0
fi

say "loading the slag kernel"
kexec -s -l "$work/vmlinuz" --initrd="$initrd" --command-line="$args" 2>/dev/null \
  || kexec -l "$work/vmlinuz" --initrd="$initrd" --command-line="$args" \
  || die "kexec could not load the slag kernel"
say "kexec into slag $(basename "$build") NOW. This OS stops; SSH to the appliance with the slag key."
sync
if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
  systemctl kexec --no-wall 2>/dev/null || systemctl kexec || kexec -e
else
  kexec -e
fi
