#!/usr/bin/env bash
# post-publish device-side verification harness.
#
# Closes the verification gap where tasks can archive on source-diff alone
# without a device run, leaving fixes unshipped to actual hardware. This
# script SSHes to every device in a manifest, runs the published installer
# for that brand, then greps the install log for the canonical CDP success
# banner.
#
# Exit zero ↔ every device's installer reached "Browser automation ready
# (CDP connected)". Any other state — ssh failure, npx non-zero, missing
# banner — is a non-zero exit with the failing device named on stderr.
#
# CONTRACT
#   Archival of any task that touches packages/create-maxy-code/** is contingent
#   on this script exiting zero against the published version. The exit code
#   and the per-device summary are quoted in the close commit body.
#
# USAGE
#   $ installer-device-verify.sh <published-version>
#
#   <published-version>  e.g. 1.0.853 — appended to `npx -y @rubytech/create-<brand>@<version>`.
#
# MANIFEST
#   Path: $MAXY_VERIFY_MANIFEST (default $HOME/.maxy-verify-devices.json).
#   Format: JSON array of devices; each entry:
#     {
#       "name":    "maxy-pi-test",            // free-form display name
#       "brand":   "maxy",                    // npm package suffix (@rubytech/create-<brand>)
#       "configDir": ".maxy",                 // brand configDir; logs live in $HOME/<configDir>/logs/
#       "sshTarget": "admin@maxytest.local",  // user@host for ssh
#       "sshPass":  "password"                // optional; uses sshpass if present
#     }
#   sshTarget supports inline port via "user@host:port" — script splits if present.
#   sshPass omission → relies on existing ssh keys / agent.
#
# OUTPUT
#   Per-device summary line on stdout.
#   Detailed log: $HOME/.maxy/logs/installer-verify-<runId>.log (created if absent).
#
# DEPENDENCIES
#   ssh (always), sshpass (only if any device uses sshPass), jq (always — the
#   manifest is JSON; bash-only parse would be a CVE waiting to happen).

set -euo pipefail

if [[ $# -ne 1 ]]; then
  echo "ERROR: missing argument <published-version>" >&2
  echo "Usage: $0 <published-version>" >&2
  echo "Example: $0 1.0.853" >&2
  exit 2
fi

VERSION="$1"
# Pattern-validate version before any remote-shell interpolation. The version
# flows into `npx -y @rubytech/create-<brand>@$VERSION` over SSH; a stray
# semicolon or backtick would land inside the remote shell. Operator-owned
# input but the principle is validate-at-boundary.
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
  echo "ERROR: invalid version '$VERSION' — expected semver (e.g. 1.0.853 or 1.0.853-rc.1)" >&2
  exit 2
fi

MANIFEST="${MAXY_VERIFY_MANIFEST:-$HOME/.maxy-verify-devices.json}"
RUN_ID="$(date +%Y%m%dT%H%M%SZ)-$$"
SUMMARY_DIR="$HOME/.maxy/logs"
SUMMARY_LOG="$SUMMARY_DIR/installer-verify-$RUN_ID.log"

if [[ ! -f "$MANIFEST" ]]; then
  cat >&2 <<EOF
ERROR: device manifest not found at $MANIFEST.

Create one with this shape (JSON array, one entry per device):
[
  {
    "name":      "maxy-pi-test",
    "brand":     "maxy",
    "configDir": ".maxy",
    "sshTarget": "admin@maxytest.local",
    "sshPass":   "password"
  }
]

Or set \$MAXY_VERIFY_MANIFEST to point elsewhere.
Device list reference: ~/.claude/projects/-Users-neo-getmaxy/memory/reference_device_ssh.md
EOF
  exit 2
fi

if ! command -v jq >/dev/null 2>&1; then
  echo "ERROR: jq is required (manifest is JSON)." >&2
  echo "  macOS:    brew install jq" >&2
  echo "  Ubuntu:   sudo apt-get install -y jq" >&2
  exit 2
fi

mkdir -p "$SUMMARY_DIR"
: > "$SUMMARY_LOG"

# Resolve sshpass once: required only if any manifest entry has sshPass set.
NEEDS_SSHPASS=$(jq -r 'any(.[]; .sshPass != null and .sshPass != "")' "$MANIFEST")
if [[ "$NEEDS_SSHPASS" == "true" ]] && ! command -v sshpass >/dev/null 2>&1; then
  echo "ERROR: manifest references sshPass but sshpass is not installed." >&2
  echo "  macOS:    brew install hudochenkov/sshpass/sshpass" >&2
  echo "  Ubuntu:   sudo apt-get install -y sshpass" >&2
  exit 2
fi

DEVICE_COUNT=$(jq 'length' "$MANIFEST")
FAILED=0

# Common ssh hardening: short timeouts, no host-key prompt that would block
# when adding a new Pi to the fleet, and StrictHostKeyChecking=accept-new so
# fingerprints are recorded the first time but rejected on mismatch after.
SSH_OPTS=(
  -o "ConnectTimeout=10"
  -o "ServerAliveInterval=15"
  -o "ServerAliveCountMax=2"
  -o "StrictHostKeyChecking=accept-new"
  -o "BatchMode=no"
)

run_ssh() {
  local target="$1" pass="$2"
  shift 2
  if [[ -n "$pass" ]]; then
    SSHPASS="$pass" sshpass -e ssh "${SSH_OPTS[@]}" "$target" "$@"
  else
    ssh "${SSH_OPTS[@]}" "$target" "$@"
  fi
}

# Allowed brand pattern matches what bundle.js validates: lowercase
# alphanumeric, hyphens. Reject anything else before interpolating into
# remote shell commands. configDir mirrors the brand convention (a leading
# dot then the same character class), so the same shape passes through.
# `$NAME` is interpolated into `--hostname "$NAME"` over SSH — same boundary
# discipline applies; allow dots so it can carry mDNS hostnames like
# `maxytest.local`, but never quote characters or shell metacharacters.
ALLOWED='^[a-z0-9][a-z0-9-]*$'
ALLOWED_HOSTNAME='^[a-zA-Z0-9][a-zA-Z0-9.-]*$'

echo "installer-device-verify run=$RUN_ID version=$VERSION devices=$DEVICE_COUNT" | tee -a "$SUMMARY_LOG"

for i in $(seq 0 $((DEVICE_COUNT - 1))); do
  NAME=$(jq -r ".[$i].name"      "$MANIFEST")
  BRAND=$(jq -r ".[$i].brand"     "$MANIFEST")
  CONFIGDIR=$(jq -r ".[$i].configDir" "$MANIFEST")
  TARGET=$(jq -r ".[$i].sshTarget" "$MANIFEST")
  PASS=$(jq -r ".[$i].sshPass // \"\"" "$MANIFEST")

  if [[ ! "$BRAND" =~ $ALLOWED ]]; then
    echo "FAIL  $NAME — invalid brand '$BRAND' (must match $ALLOWED)" | tee -a "$SUMMARY_LOG"
    FAILED=1
    continue
  fi

  if [[ ! "$NAME" =~ $ALLOWED_HOSTNAME ]]; then
    echo "FAIL  device name '$NAME' rejected — must match $ALLOWED_HOSTNAME" | tee -a "$SUMMARY_LOG"
    FAILED=1
    continue
  fi

  # configDir always begins with '.' (e.g. .maxy); strip the dot and validate
  # the remainder with the same allowed-character class.
  CD_TAIL="${CONFIGDIR#.}"
  if [[ "$CD_TAIL" == "$CONFIGDIR" || ! "$CD_TAIL" =~ $ALLOWED ]]; then
    echo "FAIL  $NAME — invalid configDir '$CONFIGDIR' (must be a leading dot + $ALLOWED)" | tee -a "$SUMMARY_LOG"
    FAILED=1
    continue
  fi

  echo "" | tee -a "$SUMMARY_LOG"
  echo "[$NAME] brand=$BRAND target=$TARGET version=$VERSION" | tee -a "$SUMMARY_LOG"

  # Step 1 — install. Run npx with auto-yes; redirect to the device's own
  # install log path so the summary log on the operator side stays clean,
  # and so the device-side log matches what reference_device_ssh.md would
  # tail.
  set +e
  run_ssh "$TARGET" "$PASS" "npx -y @rubytech/create-$BRAND@$VERSION --hostname \"$NAME\"" >>"$SUMMARY_LOG" 2>&1
  INSTALL_RC=$?
  set -e

  if [[ $INSTALL_RC -ne 0 ]]; then
    echo "FAIL  $NAME — install exited $INSTALL_RC (see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
    FAILED=1
    continue
  fi

  # Step 2 — confirm a terminal-success banner in the latest install log.
  # Pick the newest install-*.log by mtime (`ls -t`) and grep for either of
  # the installer's two terminal-success markers emitted by
  # packages/create-maxy-code/src/index.ts:
  #   • DISPLAY_MODE=virtual (Pi, headless VNC) →
  #     "Browser automation ready (CDP connected)"  (index.ts:3012)
  #   • DISPLAY_MODE=native  (laptop, on-demand Chromium) →
  #     "[cdp-check] skipped reason=native-display"  (index.ts:3004)
  # Both terminate the install successfully; either is a pass. Hard-coding
  # only the virtual-mode banner would mark every laptop install FAIL even
  # though the laptop install correctly skips CDP probing because there is
  # no persistent Chromium running for CDP to talk to.
  REMOTE_CHECK=$(cat <<'REMOTE'
set -euo pipefail
LOG_DIR="$HOME/__CONFIGDIR__/logs"
LATEST=$(ls -t "$LOG_DIR"/install-*.log 2>/dev/null | head -n1 || true)
if [[ -z "${LATEST:-}" ]]; then
  echo "no-install-log"
  exit 1
fi
echo "log=$LATEST"
if grep -F -q "Browser automation ready (CDP connected)" "$LATEST"; then
  echo "marker=cdp-connected"
  exit 0
fi
if grep -F -q "[cdp-check] skipped reason=native-display" "$LATEST"; then
  echo "marker=native-display-skip"
  exit 0
fi
echo "banner-not-found"
tail -n 30 "$LATEST"
exit 1
REMOTE
)
  REMOTE_CHECK="${REMOTE_CHECK//__CONFIGDIR__/$CONFIGDIR}"

  set +e
  run_ssh "$TARGET" "$PASS" "$REMOTE_CHECK" >>"$SUMMARY_LOG" 2>&1
  CHECK_RC=$?
  set -e

  if [[ $CHECK_RC -ne 0 ]]; then
    echo "FAIL  $NAME — CDP banner missing (rc=$CHECK_RC, see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
    FAILED=1
    continue
  fi

  # Step 2b — base toolchain command-presence audit. An independent check of
  # actual PATH state (not an install log): a pre-existing box that never
  # re-runs the installer keeps the tools missing with no log line, so this
  # greps the live binaries directly. python, python3, pip, and file must all
  # resolve. Linux-only: Homebrew supplies python3/pip3 but not the bare
  # python/pip aliases on darwin, so a laptop install is expected to lack them
  # and the audit skips rather than false-failing a Mac in the manifest.
  TOOLCHAIN_CHECK=$(cat <<'REMOTE'
set -uo pipefail
OS=$(uname -s)
if [[ "$OS" != "Linux" ]]; then
  echo "toolchain-check skipped reason=non-linux os=$OS"
  exit 0
fi
MISSING=""
for c in python python3 pip file; do
  command -v "$c" >/dev/null 2>&1 || MISSING+="$c "
done
if [[ -n "$MISSING" ]]; then
  echo "toolchain-missing: ${MISSING% }"
  exit 1
fi
echo "toolchain-ok python python3 pip file all on PATH"
exit 0
REMOTE
)

  set +e
  run_ssh "$TARGET" "$PASS" "$TOOLCHAIN_CHECK" >>"$SUMMARY_LOG" 2>&1
  TOOLCHAIN_RC=$?
  set -e

  if [[ $TOOLCHAIN_RC -ne 0 ]]; then
    echo "FAIL  $NAME — base toolchain command missing (rc=$TOOLCHAIN_RC, see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
    FAILED=1
    continue
  fi

  # Step 3 — standing Samba socket audit (Task 755). Reconcile the *actual*
  # listening sockets against the bind invariant, independent of any install
  # log, so this catches a drifted or re-opened box as well as a fresh
  # mis-provision. Two assertions:
  #   • smbd (TCP :445/:139) listens only on loopback or a private LAN address.
  #     A public or wildcard (0.0.0.0 / [::] / public IP) listener is internet
  #     exposure — the BSI-reported defect. Private LAN listeners are expected
  #     on a Pi (LAN+loopback); loopback-only is expected on a cloud box. The
  #     private-vs-public test mirrors isPrivateIPv4() in samba-provision.ts.
  #   • nmbd is not active (the NetBIOS DDoS-reflection service is never run).
  # Matched by port, so no root is needed to read process names.
  #
  # Loaded via `read -d ''` rather than `$(cat <<…)`: the audit body contains a
  # `case` and process substitution, whose parens break bash 3.2's command-
  # substitution scanner (operators may run this script from a macOS bash 3.2).
  # The body itself runs on the device's modern bash over ssh.
  IFS= read -r -d '' SOCKET_AUDIT <<'REMOTE' || true
set -uo pipefail

is_private_v4() {
  local ip="$1" a b _rest
  IFS=. read -r a b _rest <<<"$ip"
  [[ "$a" =~ ^[0-9]+$ && "$b" =~ ^[0-9]+$ ]] || return 1
  (( a == 10 )) && return 0
  (( a == 172 && b >= 16 && b <= 31 )) && return 0
  (( a == 192 && b == 168 )) && return 0
  (( a == 100 && b >= 64 && b <= 127 )) && return 0
  (( a == 169 && b == 254 )) && return 0
  return 1
}

BAD=""
while read -r localaddr; do
  [[ -z "$localaddr" ]] && continue
  host="${localaddr%:*}"                  # strip :port
  host="${host#'['}"; host="${host%']'}"  # strip IPv6 brackets (quoted: '[' is a glob class)
  case "$host" in
    127.*|::1) continue ;;             # loopback — allowed
  esac
  if [[ "$host" == *.*.*.* ]] && is_private_v4 "$host"; then
    continue                           # private IPv4 LAN (Pi) — allowed
  fi
  BAD+="$localaddr"$'\n'
done < <(ss -tuln 2>/dev/null | awk '{print $5}' | grep -E ':(445|139)$' || true)

NMBD=$(systemctl is-active nmbd 2>/dev/null || true)
FAIL=0
if [[ -n "${BAD//$'\n'/}" ]]; then
  echo "smbd-exposed-listener:"
  printf '%s' "$BAD"
  FAIL=1
fi
if [[ "$NMBD" == "active" ]]; then
  echo "nmbd-active"
  FAIL=1
fi
[[ $FAIL -eq 0 ]] && echo "socket-audit-ok smbd=loopback-or-private nmbd=$NMBD"
exit $FAIL
REMOTE

  set +e
  run_ssh "$TARGET" "$PASS" "$SOCKET_AUDIT" >>"$SUMMARY_LOG" 2>&1
  AUDIT_RC=$?
  set -e

  if [[ $AUDIT_RC -eq 0 ]]; then
    echo "OK    $NAME — CDP banner present; smbd loopback/LAN-only, nmbd inactive" | tee -a "$SUMMARY_LOG"
  else
    echo "FAIL  $NAME — Samba socket audit failed (rc=$AUDIT_RC, see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
    FAILED=1
  fi
done

echo "" | tee -a "$SUMMARY_LOG"
if [[ $FAILED -eq 0 ]]; then
  echo "PASS — all $DEVICE_COUNT devices installed and reported CDP banner" | tee -a "$SUMMARY_LOG"
  echo "summary: $SUMMARY_LOG"
  exit 0
else
  echo "FAIL — at least one device did not reach CDP banner; details in $SUMMARY_LOG" | tee -a "$SUMMARY_LOG"
  echo "summary: $SUMMARY_LOG"
  exit 1
fi
