#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════════
# ⚠  SECURITY INVARIANT — DO NOT FLIP THIS REPO TO PUBLIC  ⚠
# ═══════════════════════════════════════════════════════════════════════════
# This file contains a read-only Azure Blob SAS token (AZURE_BLOB_SAS_RO
# below, Phase 17 D-04). The token grants pull access to ALL groundswxtch
# image tarballs. The trust boundary is "private-repo read access = SAS
# entitlement"; flipping the repo to public would leak the SAS via git
# history FOREVER. Revocation requires:
#   1. Azure Portal — regenerate the SAS (invalidates this constant)
#   2. Paste new SAS below, re-upload this script to the blob (release.yml)
#   3. Notify customers out-of-band to re-download install-groundswxtch.sh
#
# RO-SAS EXPIRES 2027-05-23 — after that, every customer curl 403s until a
# re-minted SAS is shipped in a new copy of this script.
#
# If you are about to change repo visibility: rotate the SAS FIRST.
# ═══════════════════════════════════════════════════════════════════════════
# deploy/docker/install-groundswxtch.sh
#
# Phase 11 canonical partner container launch script, extended in Phase 17
# (D-17) into a multi-subcommand installer. Subcommands dispatched by $1:
#
#   gswx-fetch   Download + verify + docker-load the gswx image from Azure Blob.
#                No container launch. Idempotent via docker image inspect.
#                Alias: 'fetch' (legacy).
#   network      Create the gswx-wan macvlan network. Only needed when
#                --network-mode=macvlan (advanced — default is host mode).
#   gswx-run     Launch the gswx container. Assumes the image is already loaded.
#                Alias: 'run' (legacy).
#   install      Convenience: gswx-fetch + gswx-run (host mode), or
#                gswx-fetch + network + gswx-run (macvlan mode). DEFAULT when
#                $1 is absent.
#   tools-fetch  Download + verify + docker-load the swx-tools image. No launch.
#                Lets operators upgrade the tools image independently of running.
#   tools-run    Launch the swx-tools container. Assumes the image is loaded.
#   tools        Convenience: tools-fetch + tools-run.
#   teardown     Stop + remove gswx and swx-tools containers. Add --full to also
#                remove the gswx-wan network, /var/lib/groundswxtch state, and
#                loaded images.
#
# ─── Simplest install (host mode, default) ──────────────────────────────────
#   sudo ./deploy/docker/install-groundswxtch.sh install \
#     --image-tag groundswxtch:v1.1
#
# Host mode shares the host's network namespace — no IP/subnet/gateway/NIC
# flags needed. Recommended for customer installs where the network topology
# is unknown until the box is racked. The daemon auto-detects its gateway
# via rtnetlink (CFG-13).
#
# ─── Advanced install (macvlan, multi-instance same host) ───────────────────
#   sudo ./deploy/docker/install-groundswxtch.sh install \
#     --network-mode macvlan \
#     --image-tag groundswxtch:v1.1 \
#     --ip 192.168.1.10 --subnet 192.168.1.0/24 --gateway 192.168.1.1 \
#     --parent-iface enp2s0 --wan-gateway 192.168.1.1 --role leaf
#
# Use macvlan when running multiple gswx containers on the same host (each
# needs its own IP), or when port-sharing with the host's stack is unacceptable.
#
# Encapsulates the docker run invocation that satisfies DPL-04..08 + DPL-10:
#   - All four caps (SYS_TIME, NET_RAW, IPC_LOCK, NET_ADMIN)
#   - Host or macvlan network (DPL-05 — macvlan path preserved byte-for-byte)
#   - Persistent volumes for timebeat keypair (DPL-06) and license (DPL-07)
#   - License path override to match D-09 mount layout
#   - GROUNDSWXTCH_WAN_GATEWAY (optional — daemon falls back to rtnetlink
#     when unset, per CFG-13)
#   - Role-derived TIMEBEAT_PTP2_CONCURRENT (boundary=0, leaf=3) per DPL-08
#
# Required flags by subcommand:
#   gswx-fetch       --image-tag
#   network          --subnet --gateway --parent-iface  (macvlan-only)
#   gswx-run (host)  --image-tag                        (default mode)
#   gswx-run (macvlan) --image-tag --ip --subnet --gateway --parent-iface
#                    [--wan-gateway optional]
#   install          = same as gswx-run; install chain adds network for macvlan
#   tools-fetch      none (TOOLS_VERSION defaults to 3.2.0)
#   tools-run        none (TOOLS_NETWORK defaults to host)
#   teardown         none
#
# Exit code: 0 on success; non-zero on validation, download, verify, or docker error.

set -euo pipefail

# ───── Defaults (env-overridable) ──────────────────────────────────────────
# IMAGE_TAG is REQUIRED — no default. The blob carries multiple versions in
# parallel and there is no "latest" pointer; defaulting to one would silently
# 404 every customer whenever that version gets retired. On upgrade, the
# customer reads the release notes and passes the new --image-tag (or sets
# IMAGE_TAG=...) — keeping version selection explicit and audit-friendly.
IMAGE_TAG="${IMAGE_TAG:-}"
# ───── Azure Blob distribution (Phase 17 D-04/D-17 — see banner above) ─────
# Resolved literals (2026-05-27): account swxrkdistribution, container
# swxcontainer01. RO-SAS expires 2027-05-23 (see banner).
AZURE_BLOB_BASE_URL="${AZURE_BLOB_BASE_URL:-https://swxrkdistribution.blob.core.windows.net/swxcontainer01}"
AZURE_BLOB_SAS_RO="${AZURE_BLOB_SAS_RO:-sp=rl&st=2026-05-21T21:40:09Z&se=2027-05-23T05:55:09Z&spr=https&sv=2026-02-06&sr=c&sig=HA9RA9sUQwLBKfePWwB3UhLKGlvtJgoT6z2bCU1Kb6k%3D}"
FORCE_FETCH="${FORCE_FETCH:-false}"
# Subcommand selector — see do_dispatch at the bottom for the full set.
CMD="${CMD:-}"
# Network mode selector (Phase 17 follow-on — wires --network-mode flag):
#   host    (default) — container uses host network namespace. Customer install
#                       path; no IP/subnet/gateway/NIC flags needed.
#   macvlan          — gswx-wan macvlan; needs --ip --subnet --gateway --parent-iface.
NETWORK_MODE="${NETWORK_MODE:-host}"
# swx-tools flags (consulted by tools-fetch / tools-run / tools):
TOOLS_VERSION="${TOOLS_VERSION:-3.2.0}"
# Tools network — default mirrors NETWORK_MODE so tools can see the same
# traffic as gswx. Explicit --network overrides.
TOOLS_NETWORK="${TOOLS_NETWORK:-}"
TOOLS_IP="${TOOLS_IP:-}"
TOOLS_CONTAINER_NAME="${TOOLS_CONTAINER_NAME:-swx-tools}"
# teardown --full deletes network, state-root, and images in addition to containers:
TEARDOWN_FULL="${TEARDOWN_FULL:-false}"
STATE_ROOT="${STATE_ROOT:-/var/lib/groundswxtch}"
# Docker container name + hostname for the gswx container. Constant across the
# fleet — every host's container is `gswx`, every container's hostname is `gswx`.
# This keeps the license fingerprint stable (depends on hostname) and makes
# `docker exec gswx ...` muscle-memory portable across hosts. Override only if
# you're already using the `gswx` name on the same host for something else.
CONTAINER_NAME="${CONTAINER_NAME:-gswx}"
# INSTANCE_NAME is the daemon's self-identity, surfaced as the `instance` field
# on /api/v1/stats and rendered in the SPA as "this instance". Each peer sees
# THIS string when querying us via peer-proxy, so it MUST be host-distinct in a
# multi-site fleet — otherwise the UI shows every peer as "groundswxtch" and
# the operator can't tell them apart.
# Default = host's short hostname (e.g. groundswxtch-1, groundswxtch-2, …).
# Override with --name. Passed into the container as GROUNDSWXTCH_NAME.
INSTANCE_NAME="${INSTANCE_NAME:-$(hostname -s 2>/dev/null || echo groundswxtch)}"
PEER_PORT="${PEER_PORT:-65107}"
TIMEBEAT_ENABLED="${TIMEBEAT_ENABLED:-false}"
TIMEBEAT_PTP2_ENABLED="${TIMEBEAT_PTP2_ENABLED:-false}"
TIMEBEAT_PTP2_DOMAINS="${TIMEBEAT_PTP2_DOMAINS:-115}"
TIMEBEAT_PTP2_SEATS_OFFER="${TIMEBEAT_PTP2_SEATS_OFFER:-0}"
TIMEBEAT_PTP2_SEATS_FILL="${TIMEBEAT_PTP2_SEATS_FILL:-3}"
TIMEBEAT_PTP2_INTERFACE="${TIMEBEAT_PTP2_INTERFACE:-eth0}"
TIMEBEAT_STEERING_ALGO="${TIMEBEAT_STEERING_ALGO:-sigma}"
TIMEBEAT_PTP_SYNC_ENABLED="${TIMEBEAT_PTP_SYNC_ENABLED:-false}"
TIMEBEAT_NTP_ENABLED="${TIMEBEAT_NTP_ENABLED:-false}"
TIMEBEAT_NTP_SERVER="${TIMEBEAT_NTP_SERVER:-pool.ntp.org}"
# Networking flags — required only in macvlan mode. In host mode all blank.
IP="${IP:-}"
SUBNET="${SUBNET:-}"
GATEWAY="${GATEWAY:-}"
PARENT_IFACE="${PARENT_IFACE:-}"
# WAN_GATEWAY is OPTIONAL in both modes (CFG-13). Daemon falls back to
# rtnetlink + spp tier-3 ARP when unset. Pass it only if auto-detect is wrong.
WAN_GATEWAY="${WAN_GATEWAY:-}"
# Phase 19 followup (martian-source workaround): per-host instance ID for
# the gswx-lan veth /30. Each host needs a UNIQUE value (1, 2, 3, ...) so
# tunnel-delivered mcast doesn't collide with the receiver's own gswx-lan
# IP. Unset = 0 (legacy 169.254.0.{1,2} — collides across hosts and breaks
# cross-host mcast unless `sysctl net.ipv4.conf.gswx-lan.accept_local=1` is
# applied). TODO: have the daemon auto-derive a unique /30 at startup so
# operators stop seeing this knob (see .planning/todos/pending/...).
VETH_INSTANCE="${VETH_INSTANCE:-}"
# PTP role — drives TIMEBEAT_PTP2_CONCURRENT. Default 'leaf' (most common
# customer config; passive PTP2 client). Override to 'boundary' on the
# grandmaster node.
ROLE="${ROLE:-leaf}"
# Optional peer + license + NAT (conditional per D-04):
PEER_ID="${PEER_ID:-}"
PEER_IP="${PEER_IP:-}"
TIMEBEAT_LICENSE="${TIMEBEAT_LICENSE:-}"
TIMEBEAT_PTP_SYNC_INTERFACE="${TIMEBEAT_PTP_SYNC_INTERFACE:-}"
TIMEBEAT_PTP_SYNC_DOMAIN="${TIMEBEAT_PTP_SYNC_DOMAIN:-}"
TIMEBEAT_NAT_HOST_IP="${TIMEBEAT_NAT_HOST_IP:-}"
TIMEBEAT_NAT_PEER_PUBLIC_IP="${TIMEBEAT_NAT_PEER_PUBLIC_IP:-}"
# Phase 19 D-27/D-20 revert (2026-05-31): the LAN_DEV/LAN_CIDR variables
# and their --lan-dev/--lan-cidr flag handlers were removed. The daemon is
# always-veth now (dedicated-LAN-NIC mode is gone), so there is no LAN-side
# physical interface to pin. Legacy --lan-dev / --lan-cidr flags are still
# accepted by the parser but WARN-and-ignored to give operators a clear
# upgrade signal.
# PTP2_CONCURRENT is derived from ROLE in the validation section unless overridden:
PTP2_CONCURRENT="${PTP2_CONCURRENT:-}"
# Phase 19.1 plan 04 codex HIGH-2: dev-iteration knobs. Both are no-ops on a
# production install (operators don't pass them). Used by the Playwright
# behavioral verifier to mount a modified SPA into a stock image without
# rebuilding.
#
# --extra-mount accepts a `host_path:container_path[:opts]` triple,
# forwarded verbatim to docker run as an additional `-v` flag. Can be
# repeated; values accumulate into EXTRA_MOUNTS (newline-separated).
#
# --extra-daemon-arg accepts a single token, appended verbatim to the
# container's CMD line as an extra daemon CLI flag (e.g.
# `--ui-dev=/opt/groundswxtch/ui/spa`). Can be repeated; values accumulate
# into EXTRA_DAEMON_ARGS (newline-separated).
EXTRA_MOUNTS="${EXTRA_MOUNTS:-}"
EXTRA_DAEMON_ARGS="${EXTRA_DAEMON_ARGS:-}"
# License directory (optional). Legacy convenience for one-time migration of
# license.key into ${STATE_ROOT}/license.key on first install. The canonical
# license location going forward is ${STATE_ROOT}/license.key (flat layout —
# matches the single /etc/groundswxtch mount inside the container).
LICENSE_DIR="${LICENSE_DIR:-}"

# ───── Helpers ─────────────────────────────────────────────────────────────
log()  { printf '[install-groundswxtch] %s\n' "$*" >&2; }
fail() { log "FAIL: $*"; exit 1; }

usage() {
  cat <<'EOF'
Usage: install-groundswxtch.sh <subcommand> [flags]

═══ SIMPLEST INSTALL (recommended for customer deployments) ═══

  sudo ./install-groundswxtch.sh install --image-tag groundswxtch:v1.1

Defaults to host networking — container shares the host's network namespace,
no IP/subnet/gateway flags needed. Daemon auto-detects its gateway.

═══ FIRST-RUN ON UNLICENSED HARDWARE ═══

License is OPTIONAL at boot — the container starts in degraded mode (daemon
+ REST API up; no traffic processing). First-run flow on a new machine:

  1. sudo ./install-groundswxtch.sh install --image-tag groundswxtch:v1.1
     → boots unlicensed, logs a banner explaining the degraded state.
  2. curl http://127.0.0.1:8080/api/v1/license/fingerprint
     → returns the machine's fingerprint hash; send it to swxtch.
  3. Receive your fingerprint-bound license.key, drop it at
     /var/lib/groundswxtch/license.key (sudo cp ...).
  4. curl -X POST http://127.0.0.1:8080/api/v1/license/reload
     → daemon hot-reloads the new license — no container restart needed.

═══ Subcommands ═══

  gswx-fetch    Download + verify + docker-load the gswx image. No launch.
                Use this to pre-stage a new release before bring-up, or to
                stage an upgrade (combine with --force-fetch). Idempotent.
                Alias: 'fetch' (legacy).
  network       Create the gswx-wan macvlan network. Only required when
                --network-mode=macvlan. Skip in host mode.
  gswx-run      Launch the gswx container (image must already be loaded).
                Replaces any existing gswx container with the same name —
                use this to bring up a new tag after gswx-fetch (upgrade).
                Alias: 'run' (legacy).
  install       gswx-fetch + gswx-run (host mode), or gswx-fetch + network
                + gswx-run (macvlan mode). DEFAULT when no subcommand is
                given.

  tools-fetch   Download + verify + docker-load the swx-tools image. No launch.
                Use to upgrade the tools image independently of running.
  tools-run     Launch the swx-tools container (image must already be loaded).
                Replaces any existing swx-tools container with the same name.
  tools         tools-fetch + tools-run convenience.

  teardown      Stop + remove gswx and swx-tools containers (preserves state,
                images, and network so reinstall is fast). Pass --full to also
                remove the gswx-wan network, /var/lib/groundswxtch state, and
                both loaded images.

  restart       Recreate the gswx container with the current flag values
                (e.g. after changing --name). Inherits the running container's
                --image-tag so you don't have to remember which version is
                loaded — pass --image-tag only to also upgrade in-place.

═══ Flags ═══

Required by 'fetch', 'run', 'install':
  --image-tag <ref>       Image to fetch + run (e.g. groundswxtch:v1.1, or
                          groundswxtch:v1.1.0-spp.<7-char-sha> to pin an
                          immutable build). No default — the blob carries
                          multiple versions in parallel and there is no
                          "latest" pointer. Read the release notes and pass
                          the version you want.

Network mode (drives which other flags are needed):
  --network-mode <mode>   host (default) | macvlan.
                          host    — share host's network namespace; no
                                    IP/subnet/gateway needed.
                          macvlan — dedicated container IP on a macvlan over
                                    a host NIC; needs --ip --subnet --gateway
                                    --parent-iface. Use for multi-instance
                                    on the same host.

Required by 'run' and 'install' only when --network-mode=macvlan:
  --ip <addr>             Container IP on gswx-wan (e.g. 192.168.1.10).
  --subnet <cidr>         macvlan subnet (e.g. 192.168.1.0/24).
  --gateway <addr>        macvlan gateway (e.g. 192.168.1.1).
  --parent-iface <nic>    Host NIC for macvlan parent (e.g. enp2s0).

Optional (all modes):
  --name <name>           Instance display name. Surfaces on /api/v1/stats as
                          `instance`, rendered in the SPA as "this instance",
                          and reported to peers via peer-health. Default = the
                          host's short hostname (e.g. groundswxtch-1) so each
                          peer in a fleet shows up distinctly in the UI.
                          NOTE: this is the daemon's self-identity, NOT the
                          docker container name (docker --name stays 'gswx'
                          for muscle-memory portability — `docker exec gswx`
                          works identically on every host).
  --role boundary|leaf    PTP grandmaster (boundary) or PTP2 client (leaf).
                          Default: leaf. Drives TIMEBEAT_PTP2_CONCURRENT.
  --wan-gateway <addr>    GROUNDSWXTCH_WAN_GATEWAY. Optional — daemon falls
                          back to rtnetlink (CFG-13) when unset. Pass only
                          if auto-detect picks the wrong gateway.
  --veth-instance <N>     GROUNDSWXTCH_VETH_INSTANCE (1..=255). Per-host
                          unique ID picking the gswx-lan veth /30:
                          N=1 → 169.254.1.{1,2}, N=221 → 169.254.221.{1,2}.
                          DEFAULT: auto-derived from the host's primary IPv4
                          last octet (host 192.168.4.221 → instance 221).
                          Pass --veth-instance explicitly only when the
                          auto-derived value would collide (e.g. two hosts
                          on different /24s with the same last octet) or
                          when running multiple gswx on a single host.
  --peer-id <fp>          Boundary clock libp2p fingerprint (leaf clients).
  --peer-ip <addr>        Boundary clock IP (leaf clients).
  --peer-port <port>      Boundary clock DHT port (default: 65107).
  --state-root <dir>      Persistent state root (default: /var/lib/groundswxtch).
                          Mounted inside the container at /etc/groundswxtch.
  --license-dir <dir>     Convenience: if license.key exists there but not in
                          --state-root, it gets copied on first install.
  --force-fetch           Re-download + reload even if image is already present.
  --ptp2-concurrent <int> Override role-derived TIMEBEAT_PTP2_CONCURRENT.
  --extra-mount <triple>  Extra docker -v mount, e.g.
                          /tmp/19.1-spa:/opt/groundswxtch/ui/spa.
                          Repeatable. Phase 19.1 plan 04 codex HIGH-2 dev
                          knob — production installs leave this unset.
  --extra-daemon-arg <a>  Extra CLI flag forwarded to the daemon, e.g.
                          --ui-dev=/opt/groundswxtch/ui/spa. Repeatable.
                          Phase 19.1 plan 04 codex HIGH-2 dev knob —
                          production installs leave this unset.
  -h, --help              Show this help.

'tools-fetch', 'tools-run', 'tools' flags:
  --tools-version <ver>   swx-tools version to pull. Default: 3.2.0. Override
                          when a new tools release ships.
  --network <name>        Docker network for the swx-tools container.
                          Default: host (shares the host's net namespace
                          with spp). This is the only mode where round-
                          trip mcast testing works end-to-end today —
                          send + receive both flow correctly through
                          spp's tunnel. Avoid UDP 5000 + 6000 (spp reserves
                          them at boot via KernelSocketClaim).
                          Pass 'bridge' for SEND-only tests on the docker0
                          bridge (receive is broken on docker0 due to
                          source-IP collisions + bridge IGMP forwarding
                          quirks). Pass 'gswx-wan' when gswx was installed
                          with --network-mode=macvlan.
  --tools-ip <addr>       Container IP — required only when --network is a
                          macvlan. Pick an unused IP in the macvlan subnet.
  --tools-container-name <name>  Docker container name. Default: <hostname>-tools.

'teardown' flags:
  --full                  Also remove the gswx-wan network, /var/lib/groundswxtch
                          state directory, and both loaded images. Without
                          --full, only the containers are removed (state, images,
                          and network are preserved so a re-install is fast).

The script is idempotent: fetch / tools-fetch skip curl+load if the image is
already present (unless --force-fetch); network skips create if gswx-wan
already exists; run / tools-run remove any pre-existing container with the
same name before launching.
EOF
}

# ───── Subcommand consume (Phase 17 D-17) ──────────────────────────────────
# Consume $1 if it is a known subcommand; otherwise CMD stays empty and the
# dispatch block at the bottom defaults to install behavior. Back-compat:
# plain ./install-groundswxtch.sh --image-tag ... still runs the install chain.
case "${1:-}" in
  # gswx-fetch / gswx-run are the canonical names (symmetric with
  # tools-fetch / tools-run). 'fetch' and 'run' are kept as legacy aliases
  # so older scripts and docs keep working.
  gswx-fetch|fetch)        CMD="gswx-fetch"; shift 1 ;;
  gswx-run|run)            CMD="gswx-run"; shift 1 ;;
  network|install|tools|tools-fetch|tools-run|teardown|restart)
                           CMD="$1"; shift 1 ;;
esac

# ───── Flag parsing ────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
  case "$1" in
    --network-mode)     NETWORK_MODE="$2"; shift 2 ;;
    --ip)               IP="$2"; shift 2 ;;
    --subnet)           SUBNET="$2"; shift 2 ;;
    --gateway)          GATEWAY="$2"; shift 2 ;;
    --parent-iface)     PARENT_IFACE="$2"; shift 2 ;;
    --wan-gateway)      WAN_GATEWAY="$2"; shift 2 ;;
    --veth-instance)    VETH_INSTANCE="$2"; shift 2 ;;
    --role)             ROLE="$2"; shift 2 ;;
    --peer-id)          PEER_ID="$2"; shift 2 ;;
    --peer-ip)          PEER_IP="$2"; shift 2 ;;
    --peer-port)        PEER_PORT="$2"; shift 2 ;;
    --state-root)       STATE_ROOT="$2"; shift 2 ;;
    --license-dir)      LICENSE_DIR="$2"; shift 2 ;;
    --image-tag)        IMAGE_TAG="$2"; shift 2 ;;
    --name)             INSTANCE_NAME="$2"; shift 2 ;;
    --lan-dev)
        log "WARN: --lan-dev is no longer supported (Phase 19 always-veth model); ignoring '$2'."
        shift 2 ;;
    --lan-cidr)
        log "WARN: --lan-cidr is no longer supported (Phase 19 always-veth model); ignoring '$2'."
        shift 2 ;;
    --ptp2-concurrent)  PTP2_CONCURRENT="$2"; shift 2 ;;
    --force-fetch)      FORCE_FETCH="true"; shift 1 ;;
    --tools-version)    TOOLS_VERSION="$2"; shift 2 ;;
    --network)          TOOLS_NETWORK="$2"; shift 2 ;;
    --tools-ip)         TOOLS_IP="$2"; shift 2 ;;
    --tools-container-name) TOOLS_CONTAINER_NAME="$2"; shift 2 ;;
    --full)             TEARDOWN_FULL="true"; shift 1 ;;
    --extra-mount)
        # Phase 19.1 plan 04 codex HIGH-2: dev-iteration extra docker -v mount.
        # Format: host_path:container_path[:opts]. Repeatable; values accumulate.
        if [[ -z "$EXTRA_MOUNTS" ]]; then EXTRA_MOUNTS="$2"
        else                              EXTRA_MOUNTS="${EXTRA_MOUNTS}"$'\n'"$2"
        fi
        shift 2 ;;
    --extra-daemon-arg)
        # Phase 19.1 plan 04 codex HIGH-2: extra CLI flag forwarded to the
        # daemon. Format: a single token (e.g. --ui-dev=/path or --foo=bar).
        # Repeatable; values accumulate.
        if [[ -z "$EXTRA_DAEMON_ARGS" ]]; then EXTRA_DAEMON_ARGS="$2"
        else                                   EXTRA_DAEMON_ARGS="${EXTRA_DAEMON_ARGS}"$'\n'"$2"
        fi
        shift 2 ;;
    -h|--help)          usage; exit 0 ;;
    *)                  fail "unknown flag: $1 (run with --help for usage)" ;;
  esac
done

# ───── Validate --network-mode ─────────────────────────────────────────────
case "$NETWORK_MODE" in
  host|macvlan) ;;
  *) fail "--network-mode must be 'host' or 'macvlan', got: ${NETWORK_MODE}" ;;
esac

# ───── Auto-derive --veth-instance from host's primary IPv4 last octet ─────
# Phase 19 followup: the gswx-lan veth pair needs unique IPs per host or
# tunnel-delivered mcast gets dropped as martian-source on the receiver
# (src == own gswx-lan IP). When the operator doesn't pass --veth-instance
# explicitly, derive it from the host's primary IPv4 (default-route iface).
# Each host on a typical /24 LAN has a unique last octet, so this gives
# multi-host fleets unique veth /30s out of the box. Operator can still
# override with --veth-instance N when auto-derivation would pick a
# colliding value (e.g. two hosts on different /24s that happen to share
# a last octet).
if [[ -z "$VETH_INSTANCE" ]]; then
  PRIMARY_IP=$(ip -4 -o route get 1.1.1.1 2>/dev/null | sed -nE 's/.* src ([0-9.]+).*/\1/p' | head -1)
  if [[ -n "$PRIMARY_IP" ]]; then
    RAW_OCTET="${PRIMARY_IP##*.}"
    # Temporary stop-gap clamp to 1..63: the daemon's current validator caps
    # veth-instance at 63 (one /30 per slot in 169.254.0.0/24 = 64 slots).
    # Modulo 63 keeps the field valid for any host last-octet. Collision risk
    # exists when two hosts' last octets differ by exactly a multiple of 63
    # (e.g., .1 and .64). For the current 3-host dev fleet this gives unique
    # values; for larger fleets the daemon will need its own auto-derive
    # logic with collision probing — tracked in
    # .planning/todos/pending/2026-05-31-veth-ip-autodetect.md.
    if [[ "$RAW_OCTET" -ge 1 && "$RAW_OCTET" -le 255 ]]; then
      AUTO_INSTANCE=$(( ((RAW_OCTET - 1) % 63) + 1 ))
      VETH_INSTANCE="$AUTO_INSTANCE"
      if [[ "$RAW_OCTET" -le 63 ]]; then
        log "veth-instance auto-derived from primary IPv4 ${PRIMARY_IP} → ${VETH_INSTANCE} (gswx-lan will be 169.254.${VETH_INSTANCE}.2/30)"
      else
        log "veth-instance auto-derived from primary IPv4 ${PRIMARY_IP} → ${VETH_INSTANCE} (clamped from raw last-octet ${RAW_OCTET} via ((x-1)%63)+1; gswx-lan will be 169.254.${VETH_INSTANCE}.2/30)"
      fi
    else
      log "WARN: could not auto-derive veth-instance from primary IP ${PRIMARY_IP} (last octet ${RAW_OCTET} out of range); leaving unset"
    fi
  else
    log "WARN: could not detect primary IPv4 (no default route?); leaving veth-instance unset — multi-host fleets will collide on 169.254.0.{1,2}"
  fi
else
  log "veth-instance explicitly set to ${VETH_INSTANCE} (gswx-lan will be 169.254.${VETH_INSTANCE}.2/30)"
fi

# ───── apply_kernel_tuning: rp_filter + RX throughput sysctls ───────────────
# Called from do_run (gswx install path) AND do_tools_run (swx-tools install
# path) so every host that participates in a groundswxtch fleet gets the
# same kernel tuning. Idempotent. Applies live + persists.
#
# Two reasons we tune:
#
# 1. spp's SRT tunnel preserves the original source IP. Cross-subnet mcast
#    (e.g. 192.168.4.x source on a 192.168.5.x receiver iface) would trip
#    Linux's reverse-path check and silently drop every packet
#    (IPReversePathFilter counter). Disable rp_filter; permit accept_local.
#
# 2. Real broadcast workloads burst at gigabit-class rates. Default
#    socket+netdev buffers (~200KB rmem_max, 1000 backlog) overflow long
#    before the application drains. Raise the ceilings and the netdev
#    backlog so AF_PACKET / kernel UDP consumers can absorb the burst.
#
# Values are conservative-but-effective broadcaster defaults. They match
# what high-rate mcast workloads (e.g. SMPTE 2110, MPEG-TS gateways) ship
# with. The persistent snippet survives reboot so we don't depend on the
# install script being re-run.
apply_kernel_tuning() {
  log "applying kernel tuning for groundswxtch (rp_filter=0 + RX throughput)"

  # ── rp_filter / accept_local ──
  sysctl -w net.ipv4.conf.all.rp_filter=0      >/dev/null 2>&1 || true
  sysctl -w net.ipv4.conf.default.rp_filter=0  >/dev/null 2>&1 || true
  sysctl -w net.ipv4.conf.all.accept_local=1   >/dev/null 2>&1 || true

  TUNING_DEFAULT_IFACE=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')
  if [[ -n "$TUNING_DEFAULT_IFACE" ]]; then
    sysctl -w "net.ipv4.conf.${TUNING_DEFAULT_IFACE}.rp_filter=0"    >/dev/null 2>&1 || true
    sysctl -w "net.ipv4.conf.${TUNING_DEFAULT_IFACE}.accept_local=1" >/dev/null 2>&1 || true
  fi
  if [[ -n "${PARENT_IFACE:-}" && "$PARENT_IFACE" != "$TUNING_DEFAULT_IFACE" ]]; then
    sysctl -w "net.ipv4.conf.${PARENT_IFACE}.rp_filter=0"    >/dev/null 2>&1 || true
    sysctl -w "net.ipv4.conf.${PARENT_IFACE}.accept_local=1" >/dev/null 2>&1 || true
  fi

  # ── RX throughput ──
  # Socket-level UDP receive buffers. rmem_max sets the ceiling for
  # SO_RCVBUF; rmem_default is what every socket starts with. swx-perf
  # and well-behaved consumers explicitly bump SO_RCVBUF — they need
  # rmem_max to be high enough to honour the request.
  sysctl -w net.core.rmem_default=8388608      >/dev/null 2>&1 || true   # 8 MB
  sysctl -w net.core.rmem_max=268435456        >/dev/null 2>&1 || true   # 256 MB
  sysctl -w net.core.wmem_default=2097152      >/dev/null 2>&1 || true   # 2 MB
  sysctl -w net.core.wmem_max=268435456        >/dev/null 2>&1 || true   # 256 MB
  sysctl -w net.core.optmem_max=16777216       >/dev/null 2>&1 || true   # 16 MB ancillary
  # NIC-driver-side backlog before packets hit the socket queue.
  sysctl -w net.core.netdev_max_backlog=250000 >/dev/null 2>&1 || true
  # NAPI poll budget per softirq cycle. Bump to absorb bursty input.
  sysctl -w net.core.netdev_budget=600         >/dev/null 2>&1 || true
  sysctl -w net.core.netdev_budget_usecs=8000  >/dev/null 2>&1 || true
  # UDP socket memory pressure thresholds (page-units). Floor / pressure /
  # max. Defaults are tiny on most distros.
  sysctl -w "net.ipv4.udp_mem=1048576 4194304 16777216" >/dev/null 2>&1 || true
  # Per-UDP-socket guaranteed receive buffer (bytes).
  sysctl -w net.ipv4.udp_rmem_min=131072       >/dev/null 2>&1 || true
  sysctl -w net.ipv4.udp_wmem_min=131072       >/dev/null 2>&1 || true
  # ── IGMP capacity ──
  # Default igmp_max_memberships=20 trips quickly on broadcast fleets that
  # subscribe to many groups per interface. Same for source filters.
  sysctl -w net.ipv4.igmp_max_memberships=1024 >/dev/null 2>&1 || true
  sysctl -w net.ipv4.igmp_max_msf=1024         >/dev/null 2>&1 || true

  # ── Persist for reboot survival ──
  SYSCTL_FILE="/etc/sysctl.d/99-groundswxtch.conf"
  log "writing persistent sysctls to ${SYSCTL_FILE}"
  cat >"$SYSCTL_FILE" <<'EOSYSCTL'
# groundswxtch kernel tuning — installed by install-groundswxtch.sh
# DO NOT EDIT BY HAND — re-running the install script overwrites this file.
#
# rp_filter / accept_local: spp's SRT tunnel preserves the original
# source IP. Cross-subnet mcast would otherwise be reverse-path-dropped.
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
net.ipv4.conf.all.accept_local = 1

# RX throughput: socket buffers, netdev backlog, NAPI budget, UDP memory.
# Sized for gigabit-class broadcast traffic.
net.core.rmem_default = 8388608
net.core.rmem_max = 268435456
net.core.wmem_default = 2097152
net.core.wmem_max = 268435456
net.core.optmem_max = 16777216
net.core.netdev_max_backlog = 250000
net.core.netdev_budget = 600
net.core.netdev_budget_usecs = 8000
net.ipv4.udp_mem = 1048576 4194304 16777216
net.ipv4.udp_rmem_min = 131072
net.ipv4.udp_wmem_min = 131072

# IGMP capacity: default 20 trips on broadcast fleets with many groups.
net.ipv4.igmp_max_memberships = 1024
net.ipv4.igmp_max_msf = 1024
EOSYSCTL
}

# ───── do_fetch: download + verify + docker-load (Phase 17 D-01/D-07/D-15/D-17) ─
# Standalone. Satisfies Scenario C (image-only). No network, no docker run.
# Idempotent via docker image inspect (D-07); --force-fetch overrides.
do_fetch() {
  [[ -n "$IMAGE_TAG" ]] || fail "--image-tag is required (e.g. --image-tag groundswxtch:v1.1). No default — the blob carries multiple versions in parallel; pass the version you want."

  # Derive arch + blob basename from IMAGE_TAG
  LOCAL_TAG="${IMAGE_TAG##*/}"               # ghcr.io/swxtchio/groundswxtch:v1.1 → groundswxtch:v1.1
  IMAGE_NAME="${LOCAL_TAG%%:*}"              # groundswxtch
  IMAGE_VERSION="${LOCAL_TAG##*:}"           # v1.1 OR v1.1.0-spp.<sha7>

  case "$(uname -m)" in
    x86_64)        ARCH="amd64" ;;
    aarch64|arm64) ARCH="arm64" ;;
    *) fail "unsupported architecture: $(uname -m). Hosts must be linux/amd64 or linux/arm64." ;;
  esac

  BLOB_BASENAME="${IMAGE_NAME}-${IMAGE_VERSION}-${ARCH}"
  BLOB_TARBALL_URL="${AZURE_BLOB_BASE_URL}/${BLOB_BASENAME}.tar.gz?${AZURE_BLOB_SAS_RO}"
  BLOB_SHA256_URL="${AZURE_BLOB_BASE_URL}/${BLOB_BASENAME}.tar.gz.sha256?${AZURE_BLOB_SAS_RO}"

  # D-07 idempotency: skip fetch if image already loaded (unless --force-fetch)
  # NOTE: moving-line tags re-published with new bytes will NOT be re-pulled on a
  # plain re-run — the existing local tag short-circuits. Pass --force-fetch to
  # pull updated bytes. This is intended D-07 behavior, not a bug.
  if [[ "$FORCE_FETCH" != "true" ]] && docker image inspect "$LOCAL_TAG" >/dev/null 2>&1; then
    log "image ${LOCAL_TAG} already loaded — skipping fetch (use --force-fetch to refresh)"
    return 0
  fi

  # Disk-space preflight — need ~2 GB available for staging + docker load.
  # POSIX `df -P -k` is portable across GNU coreutils, BSD (macOS), and BusyBox
  # (Alpine); the GNU-only `--output=avail` does not exist on BSD/BusyBox.
  # Skip the check when /var/lib/docker isn't a host directory (e.g. Docker
  # Desktop on macOS, where the daemon runs inside a VM and the host has no
  # such path). The docker engine surfaces its own disk-full errors at load
  # time if storage is exhausted inside the VM.
  if [[ -d /var/lib/docker ]]; then
    AVAIL_KB=$(df -P -k /var/lib/docker 2>/dev/null | awk 'NR==2 {print $4}')
    if [[ -n "$AVAIL_KB" && "$AVAIL_KB" =~ ^[0-9]+$ && "$AVAIL_KB" -lt 2097152 ]]; then
      fail "insufficient disk space in /var/lib/docker: ${AVAIL_KB} KB free, need >= 2 GB"
    fi
  fi

  # Global EXIT trap: cleans the staging dir on ANY later script exit.
  # Safe because nothing else in the script registers an EXIT trap.
  STAGING_DIR=$(mktemp -d)
  trap 'rm -rf "$STAGING_DIR"' EXIT

  log "fetching ${BLOB_BASENAME}.tar.gz from Azure Blob"
  curl -fL --proto "=https" --retry 3 --retry-delay 5 \
       -o "${STAGING_DIR}/${BLOB_BASENAME}.tar.gz" \
       "$BLOB_TARBALL_URL" \
    || fail "tarball download failed — check network, SAS expiry (2027-05-23), and that ${BLOB_BASENAME}.tar.gz exists for tag '${IMAGE_VERSION}' arch '${ARCH}'"

  log "fetching ${BLOB_BASENAME}.tar.gz.sha256"
  curl -fL --proto "=https" --retry 3 --retry-delay 5 \
       -o "${STAGING_DIR}/${BLOB_BASENAME}.tar.gz.sha256" \
       "$BLOB_SHA256_URL" \
    || fail "sha256 sidecar download failed — tarball fetched but cannot be verified, aborting"

  log "verifying SHA256 (D-15)"
  (cd "$STAGING_DIR" && sha256sum -c "${BLOB_BASENAME}.tar.gz.sha256") \
    || fail "SHA256 MISMATCH — refusing docker load. Tarball is corrupt or tampered."

  log "loading image into local docker (docker load)"
  gunzip -c "${STAGING_DIR}/${BLOB_BASENAME}.tar.gz" | docker load \
    || fail "docker load failed — image may be partially loaded; run 'docker image ls' to inspect"

  # docker save preserves the ORIGINAL image ref (ghcr.io/...). Retag locally
  # so the downstream 'docker run "$IMAGE_TAG"' finds the short local tag.
  GHCR_REF="ghcr.io/swxtchio/${IMAGE_NAME}:${IMAGE_VERSION}"
  if docker image inspect "$GHCR_REF" >/dev/null 2>&1 && ! docker image inspect "$LOCAL_TAG" >/dev/null 2>&1; then
    log "re-tagging ${GHCR_REF} → ${LOCAL_TAG} for docker run"
    docker tag "$GHCR_REF" "$LOCAL_TAG"
  fi

  log "image ${LOCAL_TAG} loaded successfully"
}

# ───── do_network: create gswx-wan docker network (Phase 11 D-03, Phase 17 D-17) ─
# Standalone, idempotent. Decoupled from do_run so future modes (host, bridge,
# customer-predefined) can plug in here without touching the container-launch path.
# Today the only shipping mode is macvlan; the follow-on networking-models phase
# adds case branches for the other modes (--network-mode flag, not yet wired).
do_network() {
  [[ -n "$SUBNET"       ]] || fail "--subnet is required (e.g. 192.168.1.0/24)"
  [[ -n "$GATEWAY"      ]] || fail "--gateway is required (e.g. 192.168.1.1)"
  [[ -n "$PARENT_IFACE" ]] || fail "--parent-iface is required (e.g. enp2s0). Run 'ip link show' to find your WAN NIC."

  if docker network inspect gswx-wan >/dev/null 2>&1; then
    log "gswx-wan already exists — skipping create"
  else
    log "creating gswx-wan macvlan (subnet=${SUBNET}, gateway=${GATEWAY}, parent=${PARENT_IFACE})"
    docker network create \
      --driver macvlan \
      --subnet="${SUBNET}" \
      --gateway="${GATEWAY}" \
      -o "parent=${PARENT_IFACE}" \
      gswx-wan
  fi
}

# ───── do_run: validate + docker run (Phase 11 D-03, Phase 17 D-17) ─────────
# Standalone. Assumes image is already loaded.
#   host mode  (default): uses --network host, no IP/subnet/gateway flags.
#   macvlan mode        : requires gswx-wan to exist (run do_network first or
#                         use the install chain).
do_run() {
  # ── Validation ──────────────────────────────────────────────────────────
  [[ -n "$IMAGE_TAG" ]] || fail "--image-tag is required (e.g. --image-tag groundswxtch:v1.1). No default — pass the version that fetch loaded."

  case "$ROLE" in
    boundary) [[ -n "$PTP2_CONCURRENT" ]] || PTP2_CONCURRENT="0" ;;
    leaf)     [[ -n "$PTP2_CONCURRENT" ]] || PTP2_CONCURRENT="3" ;;
    *)        fail "--role must be 'boundary' or 'leaf', got: ${ROLE}" ;;
  esac

  if [[ "$NETWORK_MODE" == "macvlan" ]]; then
    [[ -n "$IP"           ]] || fail "--ip is required for --network-mode=macvlan (e.g. 192.168.1.10)"
    [[ -n "$SUBNET"       ]] || fail "--subnet is required for --network-mode=macvlan (e.g. 192.168.1.0/24)"
    [[ -n "$GATEWAY"      ]] || fail "--gateway is required for --network-mode=macvlan (e.g. 192.168.1.1)"
    [[ -n "$PARENT_IFACE" ]] || fail "--parent-iface is required for --network-mode=macvlan (e.g. enp2s0)"

    # ── Precondition: gswx-wan must exist (do_network is the creator) ─────
    docker network inspect gswx-wan >/dev/null 2>&1 \
      || fail "docker network 'gswx-wan' does not exist — run 'install-groundswxtch.sh network --subnet ... --gateway ... --parent-iface ...' first, or use the 'install' subcommand which chains fetch + network + run"
  fi

  # ── State directory (Phase 13.1 durable state lives under /etc/groundswxtch) ──
  # Single mount: ${STATE_ROOT} → /etc/groundswxtch. The daemon writes config,
  # applied-state, supervision artifacts here. License file lives flat at
  # ${STATE_ROOT}/license.key and is read inside the container at
  # /etc/groundswxtch/license.key (matches production gswx mount layout).
  log "ensuring state-root exists: ${STATE_ROOT}"
  mkdir -p "${STATE_ROOT}"

  apply_kernel_tuning

  # Legacy convenience: if --license-dir was provided, copy license.key into
  # state-root on first install (no copy if state-root already has one).
  if [[ -n "$LICENSE_DIR" && -f "${LICENSE_DIR}/license.key" && ! -f "${STATE_ROOT}/license.key" ]]; then
    log "copying license from ${LICENSE_DIR}/license.key → ${STATE_ROOT}/license.key (one-time migration)"
    cp "${LICENSE_DIR}/license.key" "${STATE_ROOT}/license.key"
  fi

  # License is OPTIONAL at boot. Customers running for the first time on new
  # hardware need to launch unlicensed to discover the machine's fingerprint
  # via GET /api/v1/license/fingerprint, then request a fingerprint-bound
  # license from swxtch, drop it at ${STATE_ROOT}/license.key, and POST
  # /api/v1/license/reload (or re-run gswx-run). spp boots in degraded mode
  # when no license is present — daemon + REST API are up, but no traffic
  # processing until a valid license loads.
  if [[ ! -f "${STATE_ROOT}/license.key" ]]; then
    log "no license file at ${STATE_ROOT}/license.key — booting in degraded mode."
    log "  → after container is up: curl http://127.0.0.1:8080/api/v1/license/fingerprint"
    log "  → send fingerprint to swxtch, receive license, drop it at ${STATE_ROOT}/license.key"
    log "  → reload via: curl -X POST http://127.0.0.1:8080/api/v1/license/reload"
  fi

  # ── Build ENV_ARGS array ────────────────────────────────────────────────
  # Base env vars (always included). License-path override aligns with the
  # single ${STATE_ROOT}:/etc/groundswxtch mount layout (Phase 13.1+ daemon).
  ENV_ARGS=(
    -e "GROUNDSWXTCH_LICENSE_FILE=/etc/groundswxtch/license.key"
    -e "GROUNDSWXTCH_NAME=${INSTANCE_NAME}"
    -e "TIMEBEAT_ENABLED=${TIMEBEAT_ENABLED}"
    -e "TIMEBEAT_STEERING_ALGO=${TIMEBEAT_STEERING_ALGO}"
    -e "TIMEBEAT_PTP2_ENABLED=${TIMEBEAT_PTP2_ENABLED}"
    -e "TIMEBEAT_PTP2_DOMAINS=${TIMEBEAT_PTP2_DOMAINS}"
    -e "TIMEBEAT_PTP2_CONCURRENT=${PTP2_CONCURRENT}"
    -e "TIMEBEAT_PTP2_SEATS_OFFER=${TIMEBEAT_PTP2_SEATS_OFFER}"
    -e "TIMEBEAT_PTP2_SEATS_FILL=${TIMEBEAT_PTP2_SEATS_FILL}"
    -e "TIMEBEAT_PTP2_INTERFACE=${TIMEBEAT_PTP2_INTERFACE}"
    -e "TIMEBEAT_PTP_SYNC_ENABLED=${TIMEBEAT_PTP_SYNC_ENABLED}"
    -e "TIMEBEAT_NTP_ENABLED=${TIMEBEAT_NTP_ENABLED}"
    -e "TIMEBEAT_NTP_SERVER=${TIMEBEAT_NTP_SERVER}"
  )

  # WAN_GATEWAY: CFG-13 made this optional. Only pass when operator supplied
  # it; daemon falls back to rtnetlink + spp tier-3 ARP otherwise.
  [[ -n "$WAN_GATEWAY"                 ]] && ENV_ARGS+=(-e "GROUNDSWXTCH_WAN_GATEWAY=${WAN_GATEWAY}")
  [[ -n "$VETH_INSTANCE"               ]] && ENV_ARGS+=(-e "GROUNDSWXTCH_VETH_INSTANCE=${VETH_INSTANCE}")

  # Phase 19 D-27 revert (2026-05-31): legacy LAN env-var passthroughs
  # (LAN_DEV / LAN_CIDR / LAN_MODE) removed. Always-veth daemon has no
  # LAN-side NIC to configure.

  # Conditional env vars — only appended when non-empty:
  [[ -n "$TIMEBEAT_LICENSE"            ]] && ENV_ARGS+=(-e "TIMEBEAT_LICENSE=${TIMEBEAT_LICENSE}")
  [[ -n "$PEER_IP"                     ]] && ENV_ARGS+=(-e "TIMEBEAT_PTP2_PEER_IP=${PEER_IP}")
  [[ -n "$PEER_PORT"                   ]] && ENV_ARGS+=(-e "TIMEBEAT_PTP2_PEER_PORT=${PEER_PORT}")
  [[ -n "$PEER_ID"                     ]] && ENV_ARGS+=(-e "TIMEBEAT_PTP2_PEER_ID=${PEER_ID}")
  [[ -n "$TIMEBEAT_PTP_SYNC_INTERFACE" ]] && ENV_ARGS+=(-e "TIMEBEAT_PTP_SYNC_INTERFACE=${TIMEBEAT_PTP_SYNC_INTERFACE}")
  [[ -n "$TIMEBEAT_PTP_SYNC_DOMAIN"    ]] && ENV_ARGS+=(-e "TIMEBEAT_PTP_SYNC_DOMAIN=${TIMEBEAT_PTP_SYNC_DOMAIN}")
  # conditional: cross-NAT mesh only (D-04) — peer's private/container IP
  [[ -n "$TIMEBEAT_NAT_HOST_IP"        ]] && ENV_ARGS+=(-e "TIMEBEAT_NAT_HOST_IP=${TIMEBEAT_NAT_HOST_IP}")
  # conditional: cross-NAT mesh only (D-04) — peer's public IP
  [[ -n "$TIMEBEAT_NAT_PEER_PUBLIC_IP" ]] && ENV_ARGS+=(-e "TIMEBEAT_NAT_PEER_PUBLIC_IP=${TIMEBEAT_NAT_PEER_PUBLIC_IP}")

  # ── Build RUN_NET_ARGS based on NETWORK_MODE ────────────────────────────
  if [[ "$NETWORK_MODE" == "host" ]]; then
    RUN_NET_ARGS=(--network host)
    NET_DESCRIPTOR="host"
  else
    RUN_NET_ARGS=(--network gswx-wan --ip "${IP}")
    NET_DESCRIPTOR="macvlan ip=${IP}"
  fi

  # ── Remove any pre-existing container with the same name ────────────────
  if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
    log "removing pre-existing container ${CONTAINER_NAME}"
    docker rm -f "$CONTAINER_NAME" >/dev/null
  fi

  # ── Phase 19.1 plan 04 codex HIGH-2: extra mounts / daemon args ─────────
  # Build EXTRA_MOUNT_ARGS and EXTRA_DAEMON_ARGV from the (possibly empty)
  # EXTRA_MOUNTS / EXTRA_DAEMON_ARGS multi-line strings. Empty strings
  # produce empty arrays — the docker run line stays identical to the
  # production shape when no dev knobs are passed.
  EXTRA_MOUNT_ARGS=()
  if [[ -n "$EXTRA_MOUNTS" ]]; then
    while IFS= read -r m; do
      [[ -z "$m" ]] && continue
      EXTRA_MOUNT_ARGS+=(-v "$m")
    done <<<"$EXTRA_MOUNTS"
  fi
  EXTRA_DAEMON_ARGV=()
  if [[ -n "$EXTRA_DAEMON_ARGS" ]]; then
    while IFS= read -r a; do
      [[ -z "$a" ]] && continue
      EXTRA_DAEMON_ARGV+=("$a")
    done <<<"$EXTRA_DAEMON_ARGS"
  fi

  # ── docker run ──────────────────────────────────────────────────────────
  log "starting container ${CONTAINER_NAME} (image=${IMAGE_TAG}, network=${NET_DESCRIPTOR}, role=${ROLE})"
  if [[ ${#EXTRA_MOUNT_ARGS[@]} -gt 0 ]]; then
    log "  extra mounts: $EXTRA_MOUNTS"
  fi
  if [[ ${#EXTRA_DAEMON_ARGV[@]} -gt 0 ]]; then
    log "  extra daemon args: ${EXTRA_DAEMON_ARGV[*]}"
  fi

  docker run -d \
    --name "${CONTAINER_NAME}" \
    --hostname "${CONTAINER_NAME}" \
    --restart unless-stopped \
    "${RUN_NET_ARGS[@]}" \
    --cap-add NET_ADMIN \
    --cap-add IPC_LOCK \
    --cap-add SYS_TIME \
    --cap-add NET_RAW \
    -v "${STATE_ROOT}:/etc/groundswxtch:rw" \
    -v /dev/hugepages:/dev/hugepages \
    "${EXTRA_MOUNT_ARGS[@]}" \
    "${ENV_ARGS[@]}" \
    "${IMAGE_TAG}" \
    "${EXTRA_DAEMON_ARGV[@]}"

  log "container ${CONTAINER_NAME} started (instance=${INSTANCE_NAME}, role=${ROLE}, network=${NET_DESCRIPTOR}, ptp2-concurrent=${PTP2_CONCURRENT}, restart=unless-stopped)"
}

# ───── Tools support: derived names + arch detection ───────────────────────
# Shared by do_tools_fetch and do_tools_run.
tools_derive_names() {
  case "$(uname -m)" in
    x86_64)        TOOLS_ARCH="amd64" ;;
    aarch64|arm64) TOOLS_ARCH="arm64" ;;
    *) fail "unsupported architecture: $(uname -m). swx-tools is published for linux/amd64 (arm64 pending — see TODO in deploy/docker/swx-tools.Dockerfile)." ;;
  esac
  TOOLS_BASENAME="swx-tools-${TOOLS_VERSION}-${TOOLS_ARCH}"
  TOOLS_TARBALL_URL="${AZURE_BLOB_BASE_URL}/${TOOLS_BASENAME}.tar.gz?${AZURE_BLOB_SAS_RO}"
  TOOLS_SHA_URL="${AZURE_BLOB_BASE_URL}/${TOOLS_BASENAME}.tar.gz.sha256?${AZURE_BLOB_SAS_RO}"
  TOOLS_LOCAL_TAG="swx-tools:${TOOLS_VERSION}"
}

# ───── do_tools_fetch: download + verify + docker-load swx-tools ────────────
# Standalone. Idempotent via docker image inspect. Lets operators stage a new
# tools image independently of running it (e.g. pre-fetch on multiple hosts,
# or upgrade the image before redeploying).
do_tools_fetch() {
  tools_derive_names

  if [[ "$FORCE_FETCH" != "true" ]] && docker image inspect "$TOOLS_LOCAL_TAG" >/dev/null 2>&1; then
    log "image ${TOOLS_LOCAL_TAG} already loaded — skipping fetch (use --force-fetch to refresh)"
    return 0
  fi

  # Pre-flight HEAD: arm64 is not yet published. Surface a clean error
  # message rather than letting curl spew a vague 404 deep in the pipeline.
  HTTP_CODE=$(curl -sS -o /dev/null -w "%{http_code}" --connect-timeout 10 -I "$TOOLS_TARBALL_URL" 2>&1 || echo "000")
  if [[ "$HTTP_CODE" == "404" && "$TOOLS_ARCH" == "arm64" ]]; then
    fail "swx-tools is not yet published for linux/arm64 (HTTP 404 at ${TOOLS_BASENAME}.tar.gz). amd64 is available today; arm64 upload is pending — see TODO in deploy/docker/swx-tools.Dockerfile and the Phase 17 follow-on in .planning/todos/pending/."
  elif [[ "$HTTP_CODE" != "200" ]]; then
    fail "tarball reachability check failed (HTTP ${HTTP_CODE}) for ${TOOLS_BASENAME}.tar.gz — check network, SAS expiry (2027-05-23), and that the version '${TOOLS_VERSION}' exists in blob."
  fi

  STAGING_DIR=$(mktemp -d)
  trap 'rm -rf "$STAGING_DIR"' EXIT

  log "fetching ${TOOLS_BASENAME}.tar.gz from Azure Blob"
  curl -fL --proto "=https" --retry 3 --retry-delay 5 \
       -o "${STAGING_DIR}/${TOOLS_BASENAME}.tar.gz" \
       "$TOOLS_TARBALL_URL" \
    || fail "swx-tools tarball download failed — see preflight diagnostics above"

  log "fetching ${TOOLS_BASENAME}.tar.gz.sha256"
  curl -fL --proto "=https" --retry 3 --retry-delay 5 \
       -o "${STAGING_DIR}/${TOOLS_BASENAME}.tar.gz.sha256" \
       "$TOOLS_SHA_URL" \
    || fail "swx-tools sidecar download failed"

  log "verifying SHA256 (D-15)"
  ( cd "$STAGING_DIR" && sha256sum -c "${TOOLS_BASENAME}.tar.gz.sha256" ) \
    || fail "swx-tools SHA256 mismatch — refusing to load tampered tarball"

  log "loading image into local docker (docker load)"
  gunzip -c "${STAGING_DIR}/${TOOLS_BASENAME}.tar.gz" | docker load \
    || fail "docker load failed for swx-tools"

  log "swx-tools image ${TOOLS_LOCAL_TAG} loaded successfully"
}

# ───── do_tools_run: launch swx-tools container ────────────────────────────
# Standalone. Assumes the image is already loaded.
# --network defaults to host (mirrors gswx host-mode default). Pass --network
# gswx-wan when gswx was installed with --network-mode=macvlan, or any other
# docker network name.
do_tools_run() {
  tools_derive_names

  apply_kernel_tuning

  # Default tools network = host. This is the mode that works end-to-end:
  #   * SEND direction:    swx-tools producer on host eth0 → spp's lan-side
  #                        mcast subscription picks it up → forwarded out
  #                        via SRT tunnel to remote peer(s). Verified.
  #   * RECEIVE direction: spp on the receiving host emits decoded mcast
  #                        back onto host's lan interface → swx-tools
  #                        consumer (sharing host net namespace) joins
  #                        IGMP + reads the socket cleanly. Verified
  #                        end-to-end at 4490 pkts / 100.0% on gswx-2.
  #
  # Bridge mode (--network bridge) is ALSO supported and sidesteps the
  # UDP 5000/6000 port collision with spp's KernelSocketClaim — useful
  # for swx-perf SEND-side testing on those specific ports. But the
  # RECEIVE-side flow is broken on docker0 due to two stacked issues:
  #   1. Source-IP collision: spp preserves the original packet's src
  #      IP across the tunnel. Both ends of the bridge tunnel land at
  #      172.17.0.2 (default docker0 first-container IP), so the
  #      receiver sees mcast "from itself" and the kernel drops it.
  #   2. Bridge IGMP snooping / lack of querier on docker0 — even with
  #      multicast_snooping=0, the inject-onto-veth direction doesn't
  #      reliably get delivered to the consumer's UDP socket.
  # Until the receive-side bridge issues are fixed (and the daemon's
  # bidirectional-tunnel-mcast gap closes — see
  # `bidirectional_tunnel_mcast_gap.md`), host is the only mode where a
  # round-trip test actually returns packets.
  #
  # Customers running production mcast traffic emit on their physical
  # LAN (or whatever interface spp's lan side is bound to); the
  # swx-tools default of host mode mirrors that.
  [[ -z "$TOOLS_NETWORK" ]] && TOOLS_NETWORK="host"

  docker image inspect "$TOOLS_LOCAL_TAG" >/dev/null 2>&1 \
    || fail "image ${TOOLS_LOCAL_TAG} not loaded — run 'install-groundswxtch.sh tools-fetch --tools-version ${TOOLS_VERSION}' first, or use the 'tools' subcommand which chains tools-fetch + tools-run."

  # ── Docker network validation ───────────────────────────────────────────
  if [[ "$TOOLS_NETWORK" != "host" ]]; then
    docker network inspect "$TOOLS_NETWORK" >/dev/null 2>&1 \
      || fail "docker network '${TOOLS_NETWORK}' does not exist. Run 'docker network ls' to see available networks, or create one (e.g. 'install-groundswxtch.sh network --subnet ... --gateway ... --parent-iface ...' for the gswx-wan macvlan)."

    # macvlan networks need an explicit --ip (otherwise docker grabs the first
    # available, which can shadow another container's IP — same trap as gswx).
    NETWORK_DRIVER=$(docker network inspect "$TOOLS_NETWORK" --format '{{.Driver}}' 2>/dev/null)
    if [[ "$NETWORK_DRIVER" == "macvlan" && -z "$TOOLS_IP" ]]; then
      fail "--tools-ip is required when --network is a macvlan ('${TOOLS_NETWORK}' is driver=macvlan). Pick an unused IP in the macvlan subnet."
    fi
  fi

  # ── Remove pre-existing swx-tools container with the same name ──────────
  if docker inspect "$TOOLS_CONTAINER_NAME" >/dev/null 2>&1; then
    log "removing pre-existing container ${TOOLS_CONTAINER_NAME}"
    docker rm -f "$TOOLS_CONTAINER_NAME" >/dev/null
  fi

  # ── docker run ──────────────────────────────────────────────────────────
  log "starting swx-tools container ${TOOLS_CONTAINER_NAME} (image=${TOOLS_LOCAL_TAG}, network=${TOOLS_NETWORK}${TOOLS_IP:+, ip=${TOOLS_IP}})"

  RUN_ARGS=(
    -d
    --name "${TOOLS_CONTAINER_NAME}"
    --hostname "${TOOLS_CONTAINER_NAME}"
    --restart unless-stopped
  )
  if [[ "$TOOLS_NETWORK" == "host" ]]; then
    RUN_ARGS+=(--network host)
  else
    RUN_ARGS+=(--network "$TOOLS_NETWORK")
    [[ -n "$TOOLS_IP" ]] && RUN_ARGS+=(--ip "$TOOLS_IP")
  fi

  # NET_ADMIN/NET_RAW needed by swx-perf for raw sockets + multicast joins.
  RUN_ARGS+=(--cap-add NET_ADMIN --cap-add NET_RAW)

  docker run "${RUN_ARGS[@]}" "${TOOLS_LOCAL_TAG}"

  log "swx-tools container ${TOOLS_CONTAINER_NAME} started."
  log ""
  log "Run swx-perf inside the container with:"
  log "  docker exec -it ${TOOLS_CONTAINER_NAME} swx-perf --help"
  log ""
  # 2026-06-02: surface the correct --nic value based on the chosen network
  # mode. host net → host's primary iface (resolved at runtime via default
  # route, e.g. enp1s0). macvlan → the container's eth0 (which is the
  # macvlan child). Picking the wrong --nic is the #1 reason swx-perf sees
  # 0 packets — guard operators against it explicitly.
  HINT_IFACE_HOST=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')
  HINT_IFACE_HOST="${HINT_IFACE_HOST:-enp1s0}"
  if [[ "$TOOLS_NETWORK" == "host" ]]; then
    HINT_IFACE="$HINT_IFACE_HOST"
  else
    HINT_IFACE="eth0"
  fi
  log "─── swx-perf gotchas (read before running tests) ───────────────────────"
  log "  ports:  swx-perf rejects port < 1024 (args.cpp[31]: Invalid port)."
  log "          spp KernelSocketClaim reserves UDP 5000 + 6000. swxtch"
  log "          ecosystem reserves 9999. Use 1024+ and pick a free port."
  log "  --nic:  on THIS install (network=${TOOLS_NETWORK}), use --nic ${HINT_IFACE}"
  log "          (macvlan child sees eth0; host-net sees the host's iface)."
  if [[ "$TOOLS_NETWORK" == "host" ]]; then
    log "  veth:   if this host previously ran gswx, an orphaned gswx-lan veth"
    log "          may still exist. Do NOT use --nic gswx-lan — it's not in"
    log "          the wire mcast path anymore. Use --nic ${HINT_IFACE}."
  fi
  log ""
  if [[ "$TOOLS_NETWORK" == "host" ]]; then
    log "─── End-to-end mcast test pattern (default host mode) ──────────────────"
    log "  swx-tools shares the host's net namespace with spp — sender + "
    log "  receiver tests both work, including the round-trip through the SRT"
    log "  tunnel between peer hosts."
    log ""
    log "  1) Add a mcast route on EACH gswx (sender + receiver hosts):"
    log "     curl -X POST http://127.0.0.1:8080/api/v1/streams/multicast/add \\"
    log "       -H 'Content-Type: application/json' -d '{\"groups\":\"239.1.1.1\"}'"
    log ""
    log "  2) Send from this host's swx-tools:"
    log "     docker exec ${TOOLS_CONTAINER_NAME} swx-perf producer \\"
    log "       --nic ${HINT_IFACE} --sendto 239.1.1.1:5555 --pps 1000 --seconds 30"
    log ""
    log "  3) Receive on the peer's swx-tools (also host mode):"
    log "     docker exec ${TOOLS_CONTAINER_NAME} swx-perf consumer \\"
    log "       --nic ${HINT_IFACE} --recvfrom 239.1.1.1:5555 --seconds 30 --json-report"
    log "────────────────────────────────────────────────────────────────────────"
  elif [[ "$TOOLS_NETWORK" == "bridge" ]]; then
    log "─── Bridge mode caveat (send-only) ─────────────────────────────────────"
    log "  Bridge mode is fine for SEND tests (swx-perf producer → spp picks it"
    log "  up via lan-side mcast subscription → forwards over tunnel). The"
    log "  RECEIVE side does NOT work today on docker0:"
    log "    * Source-IP collision — both ends of the bridge tunnel land on"
    log "      172.17.0.2 (docker0 default), so the receiver sees mcast"
    log "      \"from itself\" and the kernel drops it."
    log "    * Bridge IGMP forwarding to a veth-injected mcast packet is"
    log "      unreliable even with multicast_snooping=0."
    log "  Use --network host (the default) for any round-trip test."
    log "────────────────────────────────────────────────────────────────────────"
  else
    # Macvlan path (gswx-wan or any user-supplied macvlan). swx-tools sees
    # its own macvlan child as `eth0` inside the container. The hardware
    # switch carries L2 between this container and the gswx daemon's own
    # macvlan child on the same parent NIC — both must be macvlan
    # siblings on the same parent for inter-sibling L2 to traverse the
    # switch (Docker's macvlan-bridge mode does NOT forward between
    # siblings inside the host; it goes out + comes back).
    log "─── End-to-end mcast test pattern (macvlan ${TOOLS_NETWORK}) ───────────"
    log "  swx-tools runs on its own macvlan IP (${TOOLS_IP:-<unset>}); the gswx"
    log "  daemon runs on a separate macvlan IP on the same parent NIC. The"
    log "  hardware switch handles L2 between the two macvlan siblings."
    log ""
    log "  1) Add a mcast route on EACH gswx (sender + receiver hosts):"
    log "     curl -X POST http://127.0.0.1:8080/api/v1/streams/multicast/add \\"
    log "       -H 'Content-Type: application/json' -d '{\"groups\":\"239.1.1.1\"}'"
    log ""
    log "  2) Send from this host's swx-tools:"
    log "     docker exec ${TOOLS_CONTAINER_NAME} swx-perf producer \\"
    log "       --nic eth0 --sendto 239.1.1.1:5555 --pps 1000 --seconds 30"
    log ""
    log "  3) Receive on the peer's swx-tools (any compatible mode):"
    log "     # If the peer's swx-tools is ALSO macvlan: --nic eth0"
    log "     # If the peer's swx-tools is host-net: --nic <peer-host-iface>"
    log "     docker exec ${TOOLS_CONTAINER_NAME} swx-perf consumer \\"
    log "       --nic eth0 --recvfrom 239.1.1.1:5555 --seconds 30 --json-report"
    log "────────────────────────────────────────────────────────────────────────"
  fi
}

# ───── do_restart: recreate the gswx container with current flag values ────
# Needed when an env var (e.g. GROUNDSWXTCH_NAME) changes — docker doesn't
# update env on a running container, so we must stop + remove + start fresh.
# Inspects the existing container to inherit the current --image-tag so the
# operator doesn't need to remember which version is loaded; any flag passed
# on the restart invocation (e.g. --name new-name) overrides.
do_restart() {
  local current_image
  current_image=$(docker inspect "$CONTAINER_NAME" --format '{{.Config.Image}}' 2>/dev/null || true)

  if [[ -z "$current_image" ]]; then
    fail "no running container named '${CONTAINER_NAME}' found — nothing to restart. Use 'gswx-run --image-tag ...' for a fresh launch."
  fi

  # Adopt the existing image tag unless the operator explicitly overrode it.
  if [[ -z "$IMAGE_TAG" ]]; then
    IMAGE_TAG="$current_image"
    log "restart: adopting current container's image ${IMAGE_TAG}"
  fi

  log "restart: recreating '${CONTAINER_NAME}' with INSTANCE_NAME=${INSTANCE_NAME}, ROLE=${ROLE}"
  do_run
}

# ───── do_teardown: stop + remove gswx and swx-tools containers ─────────────
# Default (no --full): only removes the containers — keeps the gswx-wan
# network, /var/lib/groundswxtch state, and loaded images so reinstall is fast.
# With --full: also removes the network, state-root, and both images. Idempotent.
do_teardown() {
  log "stopping + removing container '${CONTAINER_NAME}' (if present)"
  docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true

  log "stopping + removing container '${TOOLS_CONTAINER_NAME}' (if present)"
  docker rm -f "${TOOLS_CONTAINER_NAME}" 2>/dev/null || true

  # 2026-06-02: the gswx-lan veth pair the daemon set up on first boot
  # persists after `docker rm` (spp's VethSetup::DeleteVethPair only fires
  # when delete_on_shutdown=true is observed in a clean spp shutdown, not
  # when the container is force-removed). The stale iface is harmless on
  # disk but is a footgun for follow-on swx-perf testing: an operator
  # running `swx-perf consumer --nic gswx-lan ...` on a host that just had
  # its gswx daemon torn down sees zero packets because nothing's feeding
  # the orphaned veth anymore. Remove it idempotently so the post-teardown
  # state matches operator expectations.
  if ip link show gswx-lan >/dev/null 2>&1; then
    log "removing leftover gswx-lan veth pair (idempotent)"
    ip link delete gswx-lan 2>/dev/null || true
  fi

  if [[ "$TEARDOWN_FULL" == "true" ]]; then
    log "--full: removing gswx-wan docker network (if present)"
    docker network rm gswx-wan 2>/dev/null || true

    log "--full: removing state directory ${STATE_ROOT} (if present)"
    rm -rf "${STATE_ROOT}"

    # Remove loaded images. IMAGE_TAG may be empty (teardown doesn't require it);
    # if so, we still try removing whatever swx-tools tag is configured. Skip
    # the gswx image when IMAGE_TAG is not provided rather than guess.
    if [[ -n "$IMAGE_TAG" ]]; then
      LOCAL_TAG="${IMAGE_TAG##*/}"
      IMAGE_NAME="${LOCAL_TAG%%:*}"
      IMAGE_VERSION="${LOCAL_TAG##*:}"
      GHCR_REF="ghcr.io/swxtchio/${IMAGE_NAME}:${IMAGE_VERSION}"
      log "--full: removing gswx images ${LOCAL_TAG} and ${GHCR_REF} (if present)"
      docker image rm "$LOCAL_TAG" "$GHCR_REF" 2>/dev/null || true
    else
      log "--full: skipping gswx image removal — no --image-tag supplied (pass it to teardown to remove the loaded image)"
    fi

    log "--full: removing swx-tools:${TOOLS_VERSION} image (if present)"
    docker image rm "swx-tools:${TOOLS_VERSION}" 2>/dev/null || true
  else
    log "teardown complete (containers only). Pass --full to also remove network, state-root, and images."
  fi
}

# ───── Subcommand dispatch (D-17) ───────────────────────────────────────────
# CMD is set from $1 in the subcommand-consume block above when $1 matches a
# known subcommand. Default (CMD="") = install behavior (back-compat: plain
# ./install-groundswxtch.sh --image-tag ... works).
case "${CMD:-install}" in
  gswx-fetch)
    do_fetch
    ;;
  network)
    do_network
    ;;
  gswx-run)
    do_run
    ;;
  install)
    do_fetch
    # Only create the macvlan network when the operator explicitly asked for it.
    # Host mode (default) doesn't need gswx-wan at all.
    if [[ "$NETWORK_MODE" == "macvlan" ]]; then
      do_network
    fi
    do_run
    ;;
  tools-fetch)
    do_tools_fetch
    ;;
  tools-run)
    do_tools_run
    ;;
  tools)
    do_tools_fetch
    do_tools_run
    ;;
  teardown)
    do_teardown
    ;;
  restart)
    do_restart
    ;;
  *)
    log "FAIL: unknown subcommand: '${CMD}'. Valid subcommands: gswx-fetch, network, gswx-run, install, tools-fetch, tools-run, tools, teardown, restart. (Legacy aliases: 'fetch'='gswx-fetch', 'run'='gswx-run'.)"
    usage
    exit 1
    ;;
esac
