#!/bin/sh
# Pointnode Edge gateway installer.
#
#   curl -fsSL https://get.pointnode.io/install.sh | sudo sh
#
# ── WHY THE WHOLE SCRIPT IS ONE FUNCTION ───────────────────────────────
#
# Piping a download into a shell means the shell executes whatever arrived
# before the connection dropped. A script written as a flat sequence can be
# truncated at any line and will happily run the first half -- creating a user,
# installing a unit, and stopping before the part that would have made it work.
#
# Wrapping everything in a function that is called on the LAST line makes
# truncation safe: an incomplete download defines a function nobody calls, and
# does nothing at all.
#
# ── WHAT THIS TRUSTS ───────────────────────────────────────────────────
#
# Not the download. The release archive is checked against its published
# SHA-256 identity and then verified against a signature and root key pinned in
# this script. The signature supplies authority; the checksum gives online and
# offline operators one stable byte identity. A compromised host cannot make a
# changed archive authentic by changing its checksum too.
#
# ── AND WHAT IT CANNOT PROTECT: ITSELF ─────────────────────────────────
#
# The paragraph above is true of the ARCHIVE and false of THIS FILE. The
# pinned key protects what the script downloads; nothing protects the script
# that carries the key. `curl | sudo sh` executes whatever get.pointnode.io
# served, as root, before any line below has run -- so whoever controls that
# host controls this verification, and can simply delete it.
#
# That is inherent to the delivery method rather than a defect in the script,
# and it is written here so the guarantee is not read wider than it is. Two
# things follow. An operator who wants the stronger property downloads the
# script, reads it, and runs it from a file. And the honest claim to a
# customer is "the release is signed and the installer verifies it", never
# "the install is verified end to end".
#
# ── WHERE THE SETUP CODE GOES ──────────────────────────────────────────
#
# Edge itself receives the code on stdin: never in device.yaml, a unit, an
# environment variable, shell history or a process argument. The attended
# installer prompts after the signed release is running. Automation may use
# --setup-code-stdin; there is deliberately no argument form for this bearer
# credential.
#
# POSIX sh on purpose: a Revolution Pi has dash, and an installer that needs
# bash is an installer that fails on the appliance nobody expected.

set -eu

PN_VERSION="${PN_VERSION:-latest}"
PN_BASE_URL="${PN_BASE_URL:-https://get.pointnode.io}"
PN_CLOUD_URL="${PN_CLOUD_URL:-https://app.pointnode.io}"
# mqtt.pointnode.io, not broker.pointnode.io. The latter has never existed in
# DNS -- it was written here as a plausible-looking name and nothing resolved
# it until a gateway tried. A default that does not resolve produces a gateway
# that installs, enrols, starts, reports healthy and publishes to nowhere,
# which is the worst failure shape available. This is the name the runbook,
# the help centre and the broker's own Let's Encrypt certificate all carry.
PN_BROKER="${PN_BROKER:-mqtt.pointnode.io}"
PN_BROKER_PORT="${PN_BROKER_PORT:-8883}"
PN_PREFIX="${PN_PREFIX:-/usr/bin}"
# Where the third-party licence notices land. Fixed, like the unit paths.
PN_DOC_DIR=/usr/share/doc/pointnode-edge

# Set to 1 only to install, on purpose, a signed release OLDER than the one
# this gateway is running. Passed to the release's own PNR2 tool as
# -allow-below-current. It never lowers the committed watermark or the signed
# security floor; a release refused by either stays refused.
PN_ALLOW_BELOW_CURRENT="${PN_ALLOW_BELOW_CURRENT:-0}"
# The name the gateway answers to on the local network. Names the ROLE rather
# than the company, so a Pointnode box that is not an edge gateway cannot be
# confused with one. Avahi appends -2, -3 on collision, so several gateways on
# one network stay reachable and distinguishable.
PN_MDNS_NAME="${PN_MDNS_NAME:-pnedge}"
# 443 so the address is "https://pnedge.local" with nothing to remember after
# it, and 80 purely to redirect there -- a browser given a bare hostname tries
# http first, and a connection refused makes a working appliance look dead to
# the person least able to tell that it is not.
PN_UI_PORT="${PN_UI_PORT:-443}"
PN_UI_REDIRECT_PORT="${PN_UI_REDIRECT_PORT:-80}"
PN_CONFIG_DIR=/etc/pointnode-edge
PN_STATE_DIR=/var/lib/pointnode-edge
PN_PNR2_STATE_DIR=/var/lib/pointnode-edge-pnr2
PN_USER=pointnode-edge
PN_SERVICE=pointnode-edge
PN_SETUP_CODE=""
PN_SETUP_CODE_STDIN=0
PN_BUNDLE=""
PN_PNR2=0
PN_PNR2_DEFERRED=0
# How long to wait for the gateway service to report the installed version.
# 90 s covers a slow first start (store migration, a large spool) on real
# hardware; emulated test runners set it higher. Whole seconds, 10-900.
PN_SERVICE_START_TIMEOUT="${PN_SERVICE_START_TIMEOUT:-90}"
PN_DRY_RUN=0
PN_UNINSTALL=0
PN_DELETE_DATA=0
PN_ASSUME_YES=0
PN_EXIT_UNSUPPORTED=3
PN_EXIT_VERIFY=4
PN_EXIT_INSTALL=5
PN_EXIT_ENROL=6
PN_EXPECTED_VERSION=""
PN_REMOTE_RESTART_PENDING=0
PN_FIRST_INSTALL=1
# Host-level discovery and firewall changes are useful on a fresh gateway but
# surprising during an in-place upgrade. "auto" means first install only;
# operators can opt in or out explicitly through the matching flags or env.
PN_SET_HOSTNAME="${PN_SET_HOSTNAME:-auto}"
PN_OPEN_FIREWALL="${PN_OPEN_FIREWALL:-auto}"
# Test and image-build harnesses may point this at a captured os-release file.
# Production uses the kernel-provided path and the value is never persisted.
PN_OS_RELEASE_FILE="${PN_OS_RELEASE_FILE:-/etc/os-release}"

# The separate identity the remote-access sidecar runs as. It shares no
# process or writable state with the gateway account above; that separation is
# the whole point of the sidecar. The remote units name this account and
# group, and the local remote-approval writer names the group in
# SupplementaryGroups, so none of them can start until it exists.
PN_REMOTE_USER=pointnode-edge-remote
PN_REMOTE_STATE_DIR=/var/lib/pointnode-edge-remote
# The root-owned commissioning directory for remote trust (server-trust.pem,
# tunnel-interfaces.json). It is snapshotted into the sidecar's credential
# mount and made INACCESSIBLE to it afterwards; the tunnel helper reads it with
# CAP_DAC_READ_SEARCH. The remote-approval writer's unit lists it under
# InaccessiblePaths, and systemd refuses to set up that mount namespace unless
# the directory exists -- so an appliance missing it fails at NAMESPACE with a
# 226 the moment a session is approved, which is exactly when nobody can debug.
PN_REMOTE_CONFIG_DIR=/etc/pointnode-edge-remote
# The privileged tunnel helper's own root-owned state: its rollback floor for
# the signed target registry (internal/tunnelhelper, target-registry-floor.json).
# StateDirectory= of pointnode-edge-tunnel.service, so systemd creates it; the
# installer only ever reads it, moves a superseded floor aside within it, and
# removes it with --delete-data.
PN_TUNNEL_STATE_DIR=/var/lib/pointnode-edge-tunnel

# The tunnel interface roles are the one remote-access setting that stays local
# rather than coming from Cloud: a gateway knows its own NICs, and Cloud
# dictating interface names is fragile (internal/config/config.go says as much
# on TunnelInterfaceConfig).
#
# Set BOTH to name them explicitly. Leave them unset and the installer AUTO-
# DETECTS (detect_tunnel_interfaces): the WAN side is the interface carrying the
# default route -- the reliable half, since the box itself knows which NIC
# reaches the internet, and that is the side that must never forward -- and the
# plant side is the one OTHER wired interface that is up, but only when that is
# unambiguous. Ambiguous hardware (no default route, or several candidate wired
# interfaces) writes no block and asks to be told, because naming the
# forwarding side wrong is a safety fault, not a nuisance. Either way this is
# CONFIG ONLY: it does not enable remote access; the sidecar stays dormant until
# the gateway is commissioned.
PN_PLANT_INTERFACE="${PN_PLANT_INTERFACE:-}"
PN_WAN_INTERFACE="${PN_WAN_INTERFACE:-}"

# ── the binaries a tunnel actually needs, by absolute path ──────────────
#
# These are the paths internal/tunnelapply/command.go executes -- ipBin, wgBin,
# nftBin and sysctlBin -- written out here deliberately rather than looked up.
#
# `command -v wg` answers a different question from "will the applier's exec
# succeed". A machine carrying /sbin/wg and no /usr/bin/wg passes a PATH check
# and then fails at the one moment somebody needs the tunnel, with an exec
# error rather than an explanation. So the test below is `[ -x <path> ]` on the
# exact path that will be executed, and PATH is consulted only to explain a
# failure -- "it is installed, but not where the applier looks".
#
# That also means this reports "not ready" on a distribution that ships `ip`
# somewhere other than /usr/bin/ip. That is the correct answer rather than a
# false alarm: the applier would fail on that machine too, and the fix belongs
# in tunnelapply's path list, not in a looser check here.
#
# packaging/systemd/install_sh_test.go reads the Go constants and fails if
# these four drift out of step with them.
PN_TUNNEL_IP=/usr/bin/ip
PN_TUNNEL_WG=/usr/bin/wg
PN_TUNNEL_NFT=/usr/sbin/nft
PN_TUNNEL_SYSCTL=/usr/sbin/sysctl

# firewall-cmd is deliberately not in that list: tunnelapply stats it and
# treats "not installed" as the desired state, so its absence is not a
# deficiency to report. wg-quick(8) is absent for a different reason -- the
# applier states in its own comment that it does not use wg-quick and adds the
# routes itself, so requiring it would be a check for something nothing runs.

# Readiness findings, filled in by check_remote_access and reported at the end.
# "unknown" is a first-class answer here and is never rounded to yes or no.
PN_TUNNEL_MISSING=""
PN_TUNNEL_PACKAGES=""
PN_TUNNEL_NOTES=""
PN_WG_KERNEL=unknown
PN_WG_KERNEL_DETAIL="not tested"

main() {
  parse_args "$@"
  detect_install_state

  say_banner
  arch="$(detect_arch)"
  platform="$(detect_supported_os)"

  if [ "$PN_DRY_RUN" = "1" ]; then
    print_plan "$arch" "$platform"
    return 0
  fi

  require_root
  require_systemd

  if [ "$PN_UNINSTALL" = "1" ]; then
    uninstall
    return 0
  fi

  require_tools
  refuse_if_already_enrolled

  work="$(mktemp -d)"
  # shellcheck disable=SC2064
  trap "rm -rf '$work'" EXIT INT TERM

  version="$(resolve_version)"
  info "Installing pointnode-edge ${version} for ${arch}"

  obtain_release "$work" "$version" "$arch"
  verify "$work"
  # This probe briefly creates and removes a test WireGuard interface. Keep it
  # after release verification so an untrusted or corrupt bundle causes no
  # host mutation at all.
  check_remote_access
  install_account
  if [ "$PN_PNR2" = "1" ]; then
    install -d -m 0700 -o root -g root "${PN_PNR2_STATE_DIR}" \
      || die_install "could not create the root-owned PNR2 recovery state directory"
    install -d -m 0755 -o root -g root /run/pointnode-edge-update \
      || die_install "could not create the PNR2 runtime status directory"
    current_version="0.0.0"
    if [ -x "${PN_PREFIX}/pointnode-edge" ]; then
      current_version="$("${PN_PREFIX}/pointnode-edge" -version 2>/dev/null | awk 'NR == 1 { print $2 }')"
      [ -n "$current_version" ] || current_version="0.0.0"
    fi
    attempt="$(pnr2_attempt_id "${work}/release.manifest.json")" \
      || die_install "could not identify this verified release"
    refuse_if_update_still_finishing
    # A finished background hand-off leaves only its copy of the tool behind.
    rm -rf "${PN_PNR2_STATE_DIR}/handoff"
    repair_public_release_modes
    PN_PNR2_BOOTSTRAP="${work}/pnr2-bootstrap/bin/pointnode-edge-pnr2"
    pnr2_log="${PN_PNR2_STATE_DIR}/install-activation.log"
    trap 'pnr2_interrupted' INT TERM
    below_current_flag=""
    if [ "$PN_ALLOW_BELOW_CURRENT" = "1" ]; then
      below_current_flag="-allow-below-current"
      info "PN_ALLOW_BELOW_CURRENT=1: a release older than the running one may be installed"
    fi
    run_pnr2_activation "$work" "$pnr2_log" \
      -bundle "${work}/release.tar.gz" \
      -manifest "${work}/release.manifest.json" \
      -release-root "${work}/root.pem" \
      -attempt "$attempt" \
      -current-version "$current_version" \
      ${below_current_flag:+"$below_current_flag"}
    # shellcheck disable=SC2064
    trap "rm -rf '$work'" INT TERM
    if [ "$pnr2_rc" != "0" ]; then
      die_install "the update did not finish (details above; also kept in ${pnr2_log}). If the reason is not clear, run: ${PN_PNR2_DIAGNOSTICS}"
    fi
    case "$pnr2_result" in
      *'"state":"deferred"'*)
        PN_PNR2_DEFERRED=1
        warn "A remote session is active or its state is unknown. The verified update is staged and activation is deferred."
        ;;
      *'"state":"rolled_back"'*)
        die_install "the new release did not pass its health checks and was rolled back automatically; the previous release is running. To try it again, run the same command again."
        ;;
      *'"state":"rescue_required"'*)
        die_install "the previous release was selected but did not recover. Automatic updates remain stopped; use the reviewed PNR2 rescue procedure. Start with: ${PN_PNR2_DIAGNOSTICS}"
        ;;
      *'"state":"committed"'*|*'"state":"bootstrapped"'*) : ;;
      *) die_install "PNR2 returned an incomplete activation result" ;;
    esac
  else
    install_binary "$work"
  fi
  PN_EXPECTED_VERSION="$("${PN_PREFIX}/pointnode-edge" -version 2>/dev/null | sed -n '1p')"
  if [ "$PN_PNR2" != "1" ]; then
    install_unit "$work"
    install_udev "$work"
    install_runtime_rules "$work"
  fi
  write_config
  provision_tunnel_interface_map
  allow_privileged_ports
  if host_change_enabled "$PN_SET_HOSTNAME"; then
    publish_local_name
  else
    info "Keeping the existing host name and local discovery settings"
  fi
  free_the_web_ports
  refuse_if_ui_port_held
  if host_change_enabled "$PN_OPEN_FIREWALL"; then
    open_local_firewall
  else
    info "Keeping the existing host firewall settings"
  fi

  systemctl daemon-reload
  # Before the gateway service starts, so the local interface never shows a
  # freshly installed gateway the old "remote support is not enabled on this
  # build" -- which was really "nobody started the consent service".
  activate_remote_consent_boundary
  if [ "$PN_PNR2" != "1" ]; then
    activate_installed_services
  else
    # The root watcher is part of the rollback boundary and may be active on
    # every PNR2 appliance.
    systemctl enable --now pointnode-edge-pnr2-activate.path >/dev/null 2>&1 \
      || warn "the signed update activation watcher could not be enabled"
    systemctl enable pointnode-edge-pnr2-recover.service >/dev/null 2>&1 \
      || warn "the signed update recovery service could not be enabled"
  fi
  # Signed automatic updates are on by default. Enabling the timer is safe on
  # every install: each run first passes the service's ExecCondition, which
  # checks the config opt-out, a compiled release root and the privileged
  # rollback-readiness proof, and skips quietly (no network, no download, no
  # failed unit) until all three hold. Idempotent on a re-install.
  systemctl enable --now pointnode-edge-update.timer >/dev/null 2>&1 \
    || warn "the signed update check timer could not be enabled"
  PN_SERVICE_READY=1
  if ! systemctl enable --now "${PN_SERVICE}" >/dev/null 2>&1; then
    PN_SERVICE_READY=0
    warn "the service could not be enabled or started"
  fi

  # Deliberately not a bare call. wait_for_service returns 1 on timeout, and
  # under `set -e` that terminated the installer here -- so enrolment and the
  # closing report never ran, and an operator was left with a gateway that
  # had been installed but never introduced to Cloud, with no message saying
  # so. A slow start is worth reporting and worth continuing through: the
  # enrolment attempt below produces a far better diagnostic than silence.
  if [ "$PN_PNR2_DEFERRED" = "1" ]; then
    info "The current release remains active until remote access is idle."
  elif ! wait_for_service "$PN_EXPECTED_VERSION"; then
    PN_SERVICE_READY=0
    warn "continuing anyway -- enrolment below will show whether it recovered"
  fi

  # Refresh the sanitized readiness report now that the gateway service is up.
  # The recovery unit runs the recovery release's activation tool, which
  # republishes the proof. On a first install that is the release just
  # bootstrapped: Bootstrap names it as the recovery image (before this change it did
  # not, the unit's Condition skipped it, and a fresh install stayed "blocked"
  # with update_prerequisite_missing for ever).
  if [ "$PN_PNR2" = "1" ] && [ "$PN_SERVICE_READY" = "1" ]; then
    systemctl start pointnode-edge-pnr2-recover.service >/dev/null 2>&1 \
      || warn "the signed update readiness proof could not be refreshed"
  fi

  if [ "$PN_SETUP_CODE_STDIN" = "1" ]; then
    IFS= read -r PN_SETUP_CODE || true
  elif [ -z "$PN_SETUP_CODE" ]; then
    read_setup_code_from_terminal
  fi

  if [ "$PN_ALREADY_ENROLLED" = "1" ]; then
    PN_ENROLMENT_STATUS="connected (existing identity preserved)"
  else
    PN_ENROLMENT_STATUS="waiting for setup"
  fi
  if [ -n "$PN_SETUP_CODE" ]; then
    if enrol "$PN_SETUP_CODE"; then
      PN_ENROLMENT_STATUS="connected"
    else
      PN_ENROLMENT_STATUS="needs attention"
      PN_SETUP_CODE=""
      report
      exit "$PN_EXIT_ENROL"
    fi
    PN_SETUP_CODE=""
  elif [ "$PN_ALREADY_ENROLLED" = "1" ]; then
    info ""
    info "No setup code used. The existing gateway identity remains connected."
  else
    info ""
    info "No setup code given. The gateway is installed and running but not"
    info "yet enrolled. Enrol it later with:"
    info ""
    info "Open https://${PN_MDNS_NAME}.local to finish setup."
  fi

  # Before the relay trust: a re-enrolled gateway's remote state still pinned
  # to its previous identity is moved aside first, so the consent reconcile
  # that provisioning may trigger starts the sidecar against clean floors.
  # Every run, not only after an enrolment here: a gateway re-enrolled from the
  # local interface is repaired the next time this installer runs.
  retire_superseded_remote_state
  # Only possible once the gateway holds an enrolled identity, which is why
  # this is here and not beside the interface map. A gateway enrolled later
  # from the local interface gets it the next time this installer runs.
  provision_remote_relay_trust

  report
  [ "$PN_SERVICE_READY" = "1" ] || exit "$PN_EXIT_INSTALL"
}

read_setup_code_from_terminal() {
  # A curl-piped shell is still reading the installer from stdin, so an
  # attended prompt must use the controlling terminal explicitly. Keep the
  # open and read inside command substitution: dash treats a failed redirect
  # on the special `exec` builtin as fatal to its current shell, while the
  # isolated subshell can fail quietly on genuinely headless installs.
  if PN_TERMINAL_SETUP_CODE=$(
    exec 3<>/dev/tty || exit 1
    printf 'Setup code (leave blank to finish in the local interface): ' >&3
    IFS= read -r PN_SETUP_CODE <&3 || PN_SETUP_CODE=""
    printf '%s' "$PN_SETUP_CODE"
  ) 2>/dev/null; then
    PN_SETUP_CODE=$PN_TERMINAL_SETUP_CODE
  fi
  unset PN_TERMINAL_SETUP_CODE
}

# One command a person can run when an update needs attention. The Go
# activation tool names the same command (cmd/pointnode-edge-pnr2/progress.go).
PN_PNR2_DIAGNOSTICS="sudo journalctl -u pointnode-edge.service -u pointnode-edge-pnr2-recover.service -n 200 --no-pager"
PN_PNR2_HANDOFF_UNIT=pointnode-edge-pnr2-handoff

# An earlier interrupted install may still be finishing its update in the
# background (see pnr2_interrupted). Say so in words rather than letting the
# activation tool fail on its operation lock.
refuse_if_update_still_finishing() {
  # The engine's operation lock is held only around individual steps, not for
  # a whole trial, so also ask systemd whether a finisher is running.
  for finisher in "$PN_PNR2_HANDOFF_UNIT" pointnode-edge-pnr2-recover.service pointnode-edge-pnr2-activate.service; do
    finisher_state="$(systemctl show -p ActiveState --value "$finisher" 2>/dev/null || true)"
    case "$finisher_state" in
      active|activating|deactivating|reloading)
        die_install "an earlier update is still finishing in the background. Follow it with: sudo journalctl -fu ${PN_PNR2_HANDOFF_UNIT} -u pointnode-edge-pnr2-recover.service -- then run this installer again."
        ;;
    esac
  done
  operation_lock="${PN_PNR2_STATE_DIR}/.pnr2-operation.lock"
  [ -e "$operation_lock" ] || return 0
  command -v flock >/dev/null 2>&1 || return 0
  if ! flock -n -x "$operation_lock" true; then
    die_install "an update step is running right now. Wait a minute, then run this installer again."
  fi
}

# The activation tool's stderr is for the person at the terminal: it says
# what the trial is checking every ~15 s and closes with one plain line.
# Stream it live AND keep a copy in $2. It used to be captured whole and only
# its first 8 lines shown on failure, so a ten-minute trial was ten silent
# minutes -- long enough that an operator pressed Ctrl-C (2026-10-11 crane
# gateway incident). stdout is the machine-readable result and is kept apart.
# Sets pnr2_rc and pnr2_result.
run_pnr2_activation() {
  activation_work="$1"
  activation_log="$2"
  shift 2
  {
    activation_rc=0
    "$PN_PNR2_BOOTSTRAP" "$@" 2>&1 >"${activation_work}/pnr2-result.json" || activation_rc=$?
    printf '%s\n' "$activation_rc" >"${activation_work}/pnr2-status"
  } | tee "$activation_log" >&2 || true
  pnr2_rc="$(cat "${activation_work}/pnr2-status" 2>/dev/null || echo 1)"
  pnr2_result="$(cat "${activation_work}/pnr2-result.json" 2>/dev/null || true)"
}

# Releases published before 2026-10-11 wrote manifest.json and COMPLETE 0600
# root, so the unprivileged update checker (User=pointnode-edge) and the
# gateway's health observer could not read them and automatic updates never
# ran. The activation tool repairs this too (RepairPublicReleaseModes); doing
# it here as well covers an install whose new tool is never reached. Regular
# files only, permission bits only, never through a symlink.
repair_public_release_modes() {
  for public_file in "${PN_RELEASE_ROOT:-/opt/pointnode-edge}"/releases/*/manifest.json \
    "${PN_RELEASE_ROOT:-/opt/pointnode-edge}"/releases/*/COMPLETE; do
    [ -f "$public_file" ] && [ ! -L "$public_file" ] || continue
    chmod 0644 "$public_file" 2>/dev/null || true
  done
}

# Ctrl-C (or TERM) during activation. The terminal's SIGINT has already
# stopped the activation tool -- the shell runs this trap only after the
# foreground pipeline has exited, so its locks are free. Every step of the
# activation is journalled durably, so stopping it at any point is safe; what
# must not happen is a candidate left half-proved with nobody finishing it.
#
# So the same verified activation tool is started again, detached from this
# terminal, with -resume. Resume is the path the boot-time recovery unit uses:
# - if the new release had not been switched in yet, nothing changed and the
#   current release keeps running;
# - if it is on trial and its signed deadline has not passed, the trial is
#   finished (the gateway is started if it is not running) and the release
#   is kept only if it proves healthy;
# - otherwise the previous release is restored and proved.
# It runs the tool from THIS bundle rather than the recovery unit because the
# recovery unit runs the PREVIOUS release's copy, which may predate the fix
# that always starts the gateway. The recovery unit is the fallback, and it
# runs again at every boot regardless.
pnr2_interrupted() {
  trap '' INT TERM
  printf '\n' >&2
  info "Interrupted. The update is being finished safely in the background; you do not need to do anything."
  handoff_dir="${PN_PNR2_STATE_DIR}/handoff"
  handed_off=0
  if command -v systemd-run >/dev/null 2>&1 \
    && install -d -m 0700 -o root -g root "$handoff_dir" 2>/dev/null \
    && install -m 0700 -o root -g root "$PN_PNR2_BOOTSTRAP" "${handoff_dir}/pointnode-edge-pnr2" 2>/dev/null \
    && systemd-run --quiet --collect --unit="$PN_PNR2_HANDOFF_UNIT" \
      --description="Finish an interrupted Pointnode Edge update" \
      --property=Type=exec \
      "${handoff_dir}/pointnode-edge-pnr2" -resume >/dev/null 2>&1; then
    handed_off=1
  fi
  if [ "$handed_off" != "1" ]; then
    systemctl daemon-reload >/dev/null 2>&1 || true
    if systemctl start --no-block pointnode-edge-pnr2-recover.service >/dev/null 2>&1; then
      handed_off=1
    fi
  fi
  if [ "$handed_off" = "1" ]; then
    info "If the new release had started its trial, the trial finishes (or the previous release is restored) within its time limit."
    info "If it had not started yet, nothing changed and the current release keeps running."
    info "Follow it with: sudo journalctl -fu ${PN_PNR2_HANDOFF_UNIT} -u pointnode-edge-pnr2-recover.service"
    info "Run this installer again afterwards to see the result."
  else
    warn "the background recovery could not be started. Run this installer again with the same bundle: it finishes the interrupted update before doing anything else. Rebooting also finishes it."
  fi
  rm -rf "$work"
  exit 130
}

# ── output ──────────────────────────────────────────────────────────────
info() { printf '%s\n' "$*"; }
warn() { printf 'warning: %s\n' "$*" >&2; }

# Offline resolve_version returns "offline", not the signed release version.
# Bind the attempt to the verified manifest: a retry is stable, a new bundle
# cannot collide with a previous offline update, and the ID fits 80 characters.
pnr2_attempt_id() {
  manifest_hash="$(sha256sum "$1")" || return 1
  manifest_hash="${manifest_hash%% *}"
  case "$manifest_hash" in *[!a-f0-9]*|'') return 1;; esac
  [ "${#manifest_hash}" -eq 64 ] || return 1
  printf 'pnr2_install_%s\n' "$manifest_hash"
}
die()  { printf 'error: %s\n' "$*" >&2; exit 1; }
die_usage() { printf 'error: %s\n' "$*" >&2; usage >&2; exit 2; }
die_unsupported() { printf 'error: %s\n' "$*" >&2; exit "$PN_EXIT_UNSUPPORTED"; }
die_verify() { printf 'error: %s\n' "$*" >&2; exit "$PN_EXIT_VERIFY"; }
die_install() { printf 'error: %s\n' "$*" >&2; exit "$PN_EXIT_INSTALL"; }

usage() {
  cat <<'USAGE'
Usage: install.sh [options]
  --setup-code-stdin      read the setup code from stdin
  --bundle ARCHIVE        install an offline .tar.gz with .sig and .sha256 beside it
  --set-hostname          set the Avahi host name on this run
  --open-firewall         open only the local UI ports on this run
  --dry-run               print the installation plan and change nothing
  --uninstall             remove Pointnode Edge programs and services; keep data
  --delete-data           with --uninstall, also delete identity, config and stored data
  --yes                   confirm --delete-data without a prompt
  --help                  show this help
USAGE
}

parse_args() {
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --setup-code)
        die_usage "--setup-code is not accepted because it exposes the credential; run without it and enter the code at the prompt, or use --setup-code-stdin" ;;
      --setup-code-stdin) PN_SETUP_CODE_STDIN=1; shift ;;
      --bundle)
        [ "$#" -ge 2 ] || die_usage "--bundle needs a path"
        PN_BUNDLE="$2"; shift 2 ;;
      --set-hostname) PN_SET_HOSTNAME=1; shift ;;
      --open-firewall) PN_OPEN_FIREWALL=1; shift ;;
      --dry-run) PN_DRY_RUN=1; shift ;;
      --uninstall) PN_UNINSTALL=1; shift ;;
      --delete-data) PN_DELETE_DATA=1; shift ;;
      --yes) PN_ASSUME_YES=1; shift ;;
      --help|-h) usage; exit 0 ;;
      --*) die_usage "unknown option: $1" ;;
      pne_setup_*)
        die_usage "a setup code is not accepted as an argument; run without it and enter the code at the prompt" ;;
      *) die_usage "unexpected argument: $1" ;;
    esac
  done
  [ "$PN_DELETE_DATA" = "0" ] || [ "$PN_UNINSTALL" = "1" ] \
    || die_usage "--delete-data is only valid with --uninstall"
  [ -z "$PN_BUNDLE" ] || [ "$PN_UNINSTALL" = "0" ] \
    || die_usage "--bundle cannot be used with --uninstall"
  [ "$PN_SETUP_CODE_STDIN" = "0" ] || [ "$PN_UNINSTALL" = "0" ] \
    || die_usage "--setup-code-stdin cannot be used with --uninstall"
  validate_host_change_mode PN_SET_HOSTNAME "$PN_SET_HOSTNAME"
  validate_host_change_mode PN_OPEN_FIREWALL "$PN_OPEN_FIREWALL"
}

validate_host_change_mode() {
  setting_name="$1"
  setting_value="$2"
  case "$setting_value" in
    auto|1|0|true|false|yes|no) ;;
    *) die_usage "${setting_name} must be auto, true or false" ;;
  esac
}

detect_install_state() {
  if [ -e "${PN_PREFIX}/pointnode-edge" ] || \
     [ -f "${PN_CONFIG_DIR}/device.yaml" ] || \
     [ -d "${PN_STATE_DIR}" ]; then
    PN_FIRST_INSTALL=0
  else
    PN_FIRST_INSTALL=1
  fi
}

host_change_enabled() {
  case "$1" in
    1|true|yes) return 0 ;;
    0|false|no) return 1 ;;
    auto) [ "$PN_FIRST_INSTALL" = "1" ] ;;
  esac
}

print_plan() {
  plan_arch="$1"; plan_platform="$2"
  info "Plan only — no changes will be made."
  info "  platform  ${plan_platform} (${plan_arch})"
  if [ "$PN_UNINSTALL" = "1" ]; then
    info "  action    stop and remove Pointnode Edge programs and services"
    if [ "$PN_DELETE_DATA" = "1" ]; then
      info "  data      delete identity, configuration and stored readings after confirmation"
    else
      info "  data      keep identity, configuration and stored readings"
    fi
    return 0
  fi
  if [ -n "$PN_BUNDLE" ]; then
    info "  release   offline bundle ${PN_BUNDLE}"
  else
    info "  release   ${PN_VERSION} from ${PN_BASE_URL}"
  fi
  info "  account   ${PN_USER} and isolated remote service account"
  info "  programs  ${PN_PREFIX}/pointnode-edge and confined remote helpers"
  info "  config    ${PN_CONFIG_DIR}; existing settings are preserved"
  info "  data      ${PN_STATE_DIR}; identity and stored readings are preserved"
  info "  service   enable and start ${PN_SERVICE}.service"
  info "  local UI  https://${PN_MDNS_NAME}.local"
  if host_change_enabled "$PN_SET_HOSTNAME"; then
    info "  host name set ${PN_MDNS_NAME}.local"
  else
    info "  host name keep existing settings"
  fi
  if host_change_enabled "$PN_OPEN_FIREWALL"; then
    info "  firewall  open only ${PN_UI_REDIRECT_PORT}/tcp and ${PN_UI_PORT}/tcp"
  else
    info "  firewall  keep existing settings"
  fi
}

say_banner() {
  info "Pointnode Edge gateway installer"
  info "--------------------------------"
}

# ── preflight ───────────────────────────────────────────────────────────
require_root() {
  [ "$(id -u)" = "0" ] || die "run as root: pipe into 'sudo sh' rather than 'sh'"
}

require_systemd() {
  [ -d /run/systemd/system ] || die "systemd is required; this system is not running it"
  command -v systemctl >/dev/null 2>&1 || die "systemctl not found"
}

detect_supported_os() {
  [ -r "$PN_OS_RELEASE_FILE" ] || die_unsupported "unsupported operating system: /etc/os-release is missing"
  # The file is defined as shell assignments by os-release(5).
  ID=""; VERSION_ID=""; ID_LIKE=""
  # shellcheck disable=SC1091
  . "$PN_OS_RELEASE_FILE"
  os_id="$(printf '%s' "${ID:-}" | tr '[:upper:]' '[:lower:]')"
  os_version="${VERSION_ID:-}"
  os_major="${os_version%%.*}"
  case "$os_id" in
    debian|raspbian)
      case "$os_major" in ''|*[!0-9]*) die_unsupported "unsupported ${os_id} version: ${os_version:-unknown}" ;; esac
      [ "$os_major" -ge 12 ] || die_unsupported "${os_id} ${os_version} is no longer supported; use version 12 or newer"
      ;;
    ubuntu)
      case "$os_major" in ''|*[!0-9]*) die_unsupported "unsupported Ubuntu version: ${os_version:-unknown}" ;; esac
      [ "$os_major" -ge 22 ] || die_unsupported "Ubuntu ${os_version} is not supported; use 22.04 or newer"
      ;;
    *)
      die_unsupported "unsupported operating system: ${PRETTY_NAME:-${os_id:-unknown}}; use a supported RevPi image, Debian 12+, or Ubuntu 22.04+"
      ;;
  esac
  printf '%s %s' "${PRETTY_NAME:-$os_id}" "$os_version"
}

detect_arch() {
  machine="$(uname -m)"
  case "$machine" in
    aarch64|arm64) echo "arm64" ;;
    x86_64|amd64)  echo "amd64" ;;
    armv7l|armv7)  echo "armv7" ;;
    *) die_unsupported "unsupported architecture: ${machine}" ;;
  esac
}

require_tools() {
  # The signature proves authority; the separately published checksum gives an
  # operator and an offline transfer one stable byte identity. Both are checked
  # before extraction.
  tools="tar sha256sum"
  if [ -z "$PN_BUNDLE" ]; then
    tools="curl $tools"
  fi
  for tool in $tools; do
    command -v "$tool" >/dev/null 2>&1 || die "required tool not found: ${tool}"
  done
  # openssl verifies the release signature. Refusing to continue without it is
  # deliberate: installing an unverified binary because a tool was missing is
  # the failure this check exists to prevent.
  command -v openssl >/dev/null 2>&1 \
    || die "openssl is required to verify the release signature; install it and re-run"
}

# ── remote-access readiness ─────────────────────────────────────────────
#
# WHY THIS REPORTS AND DOES NOT REFUSE
#
# A gateway with no WireGuard is still a working gateway: it acquires, it
# aggregates and it publishes. Refusing the whole install because nftables is
# absent would take a plant's telemetry away to protect a feature nobody has
# asked for yet. So nothing below calls die(). What it does is make the answer
# arrive at install time, in the report, instead of a fortnight later as an
# exec failure in a journal nobody is reading.
#
# WHY THIS INSTALLER DOES NOT RUN apt-get
#
# `apt-get install -y wireguard-tools nftables` is one line and it was
# rejected, for four reasons that are all about where this actually runs.
#
#  1. There is frequently no route out. An OT appliance's default route points
#     into an engineering LAN with no path to a Debian mirror -- the bench
#     RevPi is exactly this. `apt-get` there does not fail fast: it stalls on
#     each mirror in turn and then reports a network error, converting a
#     successful telemetry install into several minutes of silence followed by
#     a confusing failure.
#  2. It would be the one unverified thing this script does. Everything else
#     rests on a signature checked against a key pinned in this file. apt
#     installs whatever this machine's sources.list points at, at whatever
#     version, trusted by whatever is in its keyring -- none of which this
#     installer can check. Widening a curl-into-root-shell to "and also
#     installs arbitrary distribution packages" is a much larger act than
#     installing one signed binary, and it should not ride along quietly.
#  3. On older kernels the WireGuard package pulls a DKMS module that compiles
#     against the running kernel. Doing that unasked, on an appliance that is
#     mid-shift, is not a convenience.
#  4. Package installation on a plant appliance is a change-controlled action.
#     The operator has a window for it; the installer does not get to choose
#     that moment on their behalf.
#
# So the product is a refusal with the exact command to run. An operator who
# reads the closing report can paste one line, re-run this installer, and get
# a "ready". That is a better outcome than an installer that reaches out to
# the internet from a machine that was never meant to have a route there.
check_remote_access() {
  check_tunnel_tools
  check_wireguard_kernel
}

check_tunnel_tools() {
  for spec in \
    "${PN_TUNNEL_IP}:iproute2" \
    "${PN_TUNNEL_WG}:wireguard-tools" \
    "${PN_TUNNEL_NFT}:nftables" \
    "${PN_TUNNEL_SYSCTL}:procps"; do
    tool_path="${spec%:*}"
    tool_pkg="${spec##*:}"
    if [ -x "$tool_path" ]; then
      continue
    fi

    PN_TUNNEL_MISSING="${PN_TUNNEL_MISSING} ${tool_path}"

    # PATH is not the test, but it is the difference between "install a
    # package" and "the applier is looking in the wrong place", and an
    # operator told the wrong one of those loses an afternoon. A binary that
    # is present elsewhere is therefore reported as a path disagreement and
    # NOT added to the package list: telling somebody to install software they
    # already have is how a report stops being believed.
    tool_elsewhere="$(command -v "${tool_path##*/}" 2>/dev/null || true)"
    if [ -n "$tool_elsewhere" ] && [ "$tool_elsewhere" != "$tool_path" ]; then
      PN_TUNNEL_NOTES="${PN_TUNNEL_NOTES}${tool_path} is missing, but ${tool_path##*/} is installed at ${tool_elsewhere}.
The tunnel helper executes the absolute path and does not search PATH.
"
      continue
    fi

    case " ${PN_TUNNEL_PACKAGES} " in
      *" ${tool_pkg} "*) : ;;
      *) PN_TUNNEL_PACKAGES="${PN_TUNNEL_PACKAGES} ${tool_pkg}" ;;
    esac
  done
}

# Does this kernel actually do WireGuard?
#
# The tempting test is `lsmod | grep wireguard`, and it is wrong twice over. It
# says no on a kernel with WireGuard built in (CONFIG_WIREGUARD=y, which is
# what several appliance vendors ship), and it says no on a kernel that has the
# module and has simply not loaded it -- which is every freshly booted machine,
# because nothing has yet asked for a wireguard interface. A test that answers
# "no" for two healthy machines is not a test. `modinfo` shares the second
# half of that problem, and /proc/config.gz is absent on most appliance
# kernels.
#
# The honest test is to ask the kernel for the thing the product will ask it
# for: create a WireGuard interface, then delete it. `ip link add ... type
# wireguard` is the first command internal/tunnelapply issues in a session, the
# netlink request auto-loads the module when it is a module, and the answer
# comes from the kernel rather than from an inference about a text file.
#
# It can also fail to run, and when it does this says unknown. A container
# without CAP_NET_ADMIN refuses the create on a host whose kernel is perfectly
# capable; "unknown, and here is what the kernel said" is the only truthful
# report there. Nothing in this function converts a failed attempt into a
# confident answer.
check_wireguard_kernel() {
  probe=pnwgprobe0

  if [ ! -x "$PN_TUNNEL_IP" ]; then
    PN_WG_KERNEL=unknown
    PN_WG_KERNEL_DETAIL="not tested: ${PN_TUNNEL_IP} is not installed, so nothing here could ask the kernel"
    return 0
  fi

  # A device already carrying the probe name is not ours to delete.
  if "$PN_TUNNEL_IP" link show "$probe" >/dev/null 2>&1; then
    PN_WG_KERNEL=unknown
    PN_WG_KERNEL_DETAIL="not tested: an interface named ${probe} already exists and this installer will not touch it"
    return 0
  fi

  if probe_out="$("$PN_TUNNEL_IP" link add name "$probe" type wireguard 2>&1)"; then
    if ! "$PN_TUNNEL_IP" link del "$probe" >/dev/null 2>&1; then
      warn "could not remove the probe interface ${probe}; remove it with: ${PN_TUNNEL_IP} link del ${probe}"
    fi
    PN_WG_KERNEL=yes
    PN_WG_KERNEL_DETAIL="the kernel created and removed a WireGuard interface"
    return 0
  fi

  # Keep the kernel's own words, on one line, because they are the evidence.
  probe_out="$(printf '%s' "$probe_out" | tr '\n' ' ')"

  case "$probe_out" in
    *"not permitted"*|*"Permission denied"*|*"Operation not supported on transport endpoint"*)
      PN_WG_KERNEL=unknown
      PN_WG_KERNEL_DETAIL="not tested: the attempt was refused (${probe_out}). This installer runs as root, so that is usually a container or a restricted namespace rather than a kernel without WireGuard"
      ;;
    *"Unknown device type"*|*"not supported"*|*"Operation not supported"*)
      PN_WG_KERNEL=no
      PN_WG_KERNEL_DETAIL="the kernel refused to create a WireGuard interface (${probe_out}). Either this kernel has no WireGuard support, or iproute2 is too old to ask for it"
      ;;
    *)
      PN_WG_KERNEL=unknown
      PN_WG_KERNEL_DETAIL="not tested: the attempt failed for a reason this installer does not recognise (${probe_out})"
      ;;
  esac

  # A loaded module proves the kernel has WireGuard even when the create could
  # not be attempted. This is a second opinion and never the test, because the
  # reverse does not hold: an absent module proves nothing at all.
  if [ "$PN_WG_KERNEL" = "unknown" ] && [ -d /sys/module/wireguard ]; then
    PN_WG_KERNEL=yes
    PN_WG_KERNEL_DETAIL="the wireguard module is loaded, though the interface test could not run -- ${PN_WG_KERNEL_DETAIL}"
  fi
}

# The exact command an operator should run, or nothing when this installer
# cannot name one honestly. Package names are claimed only for the Debian
# family, which is what Pointnode ships on; guessing a Fedora or Alpine package
# name that has never been tried would be the same defect in a new costume.
remediation_command() {
  [ -n "$PN_TUNNEL_PACKAGES" ] || return 0
  [ -r /etc/os-release ] || return 0

  os_id="$(sed -n 's/^ID=//p' /etc/os-release 2>/dev/null | tr -d '"' | head -n 1 || true)"
  os_like="$(sed -n 's/^ID_LIKE=//p' /etc/os-release 2>/dev/null | tr -d '"' | head -n 1 || true)"

  case " ${os_id} ${os_like} " in
    *" debian "*|*" ubuntu "*|*" raspbian "*)
      printf 'sudo apt-get update && sudo apt-get install -y%s' "$PN_TUNNEL_PACKAGES"
      ;;
    *) return 0 ;;
  esac
}

# An appliance that is already enrolled holds a device identity and durable
# state. Re-running the installer must not quietly replace either -- a
# re-enrolment is an operator decision with a certificate consequence.
refuse_if_already_enrolled() {
  if [ -s "${PN_STATE_DIR}/state/identity/device.bundle.pem" ]; then
    info "This gateway is already enrolled."
    info ""
    info "Re-running the installer will upgrade the binary and leave the"
    info "identity, configuration and stored data untouched. To move it to a"
    info "different organisation, decommission it in Pointnode first."
    PN_ALREADY_ENROLLED=1
  else
    PN_ALREADY_ENROLLED=0
  fi
}

# ── fetch and verify ────────────────────────────────────────────────────
resolve_version() {
  if [ -n "$PN_BUNDLE" ]; then
    printf '%s' "offline"
    return
  fi
  if [ "$PN_VERSION" != "latest" ]; then
    printf '%s' "$PN_VERSION"
    return
  fi
  # The same transport guards the artefact download uses, for the same
  # reason. -L follows redirects, and without --proto '=https' a redirect to
  # http:// is followed silently. The signature check below still protects
  # the bytes, so this cannot install an unsigned binary -- but it decides
  # WHICH signed release is installed, and a plaintext answer to that is one
  # an on-path attacker gets to write.
  curl -fsSL --proto '=https' --tlsv1.2 --max-time 30 "${PN_BASE_URL}/latest" \
    || die "could not determine the latest version from ${PN_BASE_URL}"
}

download() {
  work="$1"; version="$2"; arch="$3"
  base="${PN_BASE_URL}/${version}/pointnode-edge_${version}_linux_${arch}"

  # PNR2 is preferred when the signed whole-product manifest is published.
  # A missing manifest means this is an older release; retain the verified
  # legacy path during the migration window rather than making old releases
  # uninstallable.
  if curl -fsSL --proto '=https' --tlsv1.2 --max-time 60 \
      -o "${work}/release.manifest.json" "${base}.pnr2.manifest.json"; then
    PN_PNR2=1
    curl -fsSL --proto '=https' --tlsv1.2 --max-time 300 \
      -o "${work}/release.tar.gz" "${base}.pnr2.tar.gz" \
      || die "download failed: ${base}.pnr2.tar.gz"
    curl -fsSL --proto '=https' --tlsv1.2 --max-time 60 \
      -o "${work}/release.tar.gz.sig" "${base}.pnr2.tar.gz.sig" \
      || die "signature download failed"
    curl -fsSL --proto '=https' --tlsv1.2 --max-time 60 \
      -o "${work}/release.tar.gz.sha256" "${base}.pnr2.tar.gz.sha256" \
      || die_verify "PNR2 releases require their published checksum"
    return
  fi

  curl -fsSL --proto '=https' --tlsv1.2 --max-time 300 \
    -o "${work}/release.tar.gz" "${base}.tar.gz" \
    || die "download failed: ${base}.tar.gz"
  curl -fsSL --proto '=https' --tlsv1.2 --max-time 60 \
    -o "${work}/release.tar.gz.sig" "${base}.tar.gz.sig" \
    || die "signature download failed"
  curl -fsSL --proto '=https' --tlsv1.2 --max-time 60 \
    -o "${work}/release.tar.gz.sha256" "${base}.tar.gz.sha256" \
    || warn "this older release has no checksum file; its signature is still required"
}

obtain_release() {
  release_work="$1"; release_version="$2"; release_arch="$3"
  if [ -z "$PN_BUNDLE" ]; then
    download "$release_work" "$release_version" "$release_arch"
    return
  fi

  [ -f "$PN_BUNDLE" ] || die_verify "offline bundle not found: ${PN_BUNDLE}"
  [ -f "${PN_BUNDLE}.sig" ] || die_verify "offline bundle signature not found: ${PN_BUNDLE}.sig"
  cp "$PN_BUNDLE" "${release_work}/release.tar.gz" \
    || die_verify "could not read the offline bundle"
  cp "${PN_BUNDLE}.sig" "${release_work}/release.tar.gz.sig" \
    || die_verify "could not read the offline bundle signature"
  if [ -f "${PN_BUNDLE}.sha256" ]; then
    cp "${PN_BUNDLE}.sha256" "${release_work}/release.tar.gz.sha256" \
      || die_verify "could not read the offline bundle checksum"
  else
    warn "this older offline release has no checksum file; its signature is still required"
  fi
  manifest="${PN_BUNDLE%.tar.gz}.manifest.json"
  if [ -f "$manifest" ]; then
    cp "$manifest" "${release_work}/release.manifest.json" \
      || die_verify "could not read the offline PNR2 manifest"
    PN_PNR2=1
    [ -f "${PN_BUNDLE}.sha256" ] \
      || die_verify "an offline PNR2 bundle requires its .sha256 file"
  fi
}

# The pinned release root. A signature checked against a key fetched from the
# same place as the file proves nothing; this key ships inside the installer
# and is the reason the download can be trusted.
release_root_pem() {
  cat <<'PEM'
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA6YPUnknrRPQXJ9TW2BCrOhoCnSq
pHDnay8VnLjJQu3hb3rXZ03EJFTy1izCgp2vTqm/TmjF8arA7oEVig+dfA==
-----END PUBLIC KEY-----
PEM
}

verify() {
  work="$1"
  release_root_pem > "${work}/root.pem"

  if grep -q REPLACE_WITH "${work}/root.pem"; then
    # Fail closed. A placeholder key must never become "verification skipped".
    die_verify "this installer has no release root key compiled in; refusing to install an unverified binary"
  fi

  if [ -f "${work}/release.tar.gz.sha256" ]; then
    expected="$(tr -d ' \t\r\n' < "${work}/release.tar.gz.sha256" 2>/dev/null || true)"
    case "$expected" in
      ''|*[!0-9a-f]*) die_verify "release checksum is not a lowercase SHA-256 value" ;;
    esac
    [ "${#expected}" -eq 64 ] || die_verify "release checksum is not 64 characters"
    actual="$(sha256sum "${work}/release.tar.gz" | awk '{print $1}')" \
      || die_verify "could not calculate the release checksum"
    [ "$actual" = "$expected" ] \
      || die_verify "release checksum did not match; refusing to install"
    info "Release checksum verified."
  else
    warn "release checksum is unavailable; verifying the mandatory signature"
  fi

  openssl dgst -sha256 -verify "${work}/root.pem" \
    -signature "${work}/release.tar.gz.sig" "${work}/release.tar.gz" >/dev/null 2>&1 \
    || die_verify "release signature did not verify; refusing to install"

  info "Release signature verified."

  if [ "$PN_PNR2" = "1" ]; then
    [ -s "${work}/release.manifest.json" ] \
      || die_verify "PNR2 release manifest is missing"
    mkdir -p "${work}/pnr2-bootstrap"
    tar -xzf "${work}/release.tar.gz" -C "${work}/pnr2-bootstrap" \
      ./bin/pointnode-edge-pnr2 \
      || die_verify "could not extract the signed PNR2 activation tool"
    [ -f "${work}/pnr2-bootstrap/bin/pointnode-edge-pnr2" ] \
      || die_verify "PNR2 activation tool is missing"
    chmod 0750 "${work}/pnr2-bootstrap/bin/pointnode-edge-pnr2"
    return 0
  fi

  tar -xzf "${work}/release.tar.gz" -C "${work}" || die_verify "could not unpack the release"
  for required_program in \
    pointnode-edge \
    pointnode-edge-remote \
    pointnode-edge-remote-authority \
    pointnode-edge-tunnel; do
    [ -f "${work}/${required_program}" ] \
      || die_install "release archive does not contain ${required_program}"
  done
  for required_unit in \
    pointnode-edge.service \
    pointnode-edge-remote.service \
    pointnode-edge-remote-authority@.service \
    pointnode-edge-remote-authority.socket \
    pointnode-edge-remote-credential.path \
    pointnode-edge-remote-credential.service \
    pointnode-edge-tunnel.socket \
    pointnode-edge-tunnel.service; do
    [ -f "${work}/packaging/systemd/${required_unit}" ] \
      || die_install "release archive does not contain ${required_unit}"
  done
}

# ── uninstall ─────────────────────────────────────────────────────────
refuse_unsafe_uninstall() {
  operation_lock="${PN_PNR2_STATE_DIR}/.pnr2-operation.lock"
  session_lock=/run/pointnode-edge-update/session.lock
  for lock_path in "$operation_lock" "$session_lock"; do
    [ -e "$lock_path" ] || continue
    command -v flock >/dev/null 2>&1 \
      || die_install "Uninstall cannot safely check for an update or remote session because flock is unavailable. Install util-linux, then try again."
    if ! flock -n -x "$lock_path" true; then
      case "$lock_path" in
        "$operation_lock")
          die_install "An update is in progress. Wait for it to finish, then uninstall again."
          ;;
        *)
          die_install "Remote access is in use. End the remote session, then uninstall again."
          ;;
      esac
    fi
  done
}

uninstall() {
  refuse_unsafe_uninstall
  info "Removing Pointnode Edge programs and services."
  for unit in \
    pointnode-edge.service \
    pointnode-edge-update.timer \
    pointnode-edge-pnr2-activate.path \
    pointnode-edge-pnr2-recover.service \
    pointnode-edge-remote.service \
    pointnode-edge-remote-authority.socket \
    pointnode-edge-remote-credential.path \
    pointnode-edge-remote-credential.service \
    pointnode-edge-tunnel.socket; do
    systemctl disable --now "$unit" >/dev/null 2>&1 || true
  done
  for unit in \
    pointnode-edge.service \
    pointnode-edge-update.service \
    pointnode-edge-update.timer \
    pointnode-edge-pnr2-activate.path \
    pointnode-edge-pnr2-activate.service \
    pointnode-edge-pnr2-recover.service \
    pointnode-edge-remote.service \
    pointnode-edge-remote-authority@.service \
    pointnode-edge-remote-authority.socket \
    pointnode-edge-remote-credential.path \
    pointnode-edge-remote-credential.service \
    pointnode-edge-tunnel.socket \
    pointnode-edge-tunnel.service; do
    rm -f "/etc/systemd/system/${unit}"
  done
  rm -f /etc/udev/rules.d/99-pointnode-edge-leds.rules
  rm -f "${PN_DOC_DIR}/THIRD_PARTY_NOTICES.txt"
  rmdir "${PN_DOC_DIR}" >/dev/null 2>&1 || true
  rm -f /etc/tmpfiles.d/pointnode-edge-remote-status.conf
  rm -f "/etc/systemd/system/${PN_SERVICE}.service.d/10-bind-privileged-ports.conf"
  rmdir "/etc/systemd/system/${PN_SERVICE}.service.d" >/dev/null 2>&1 || true
  for program in \
    pointnode-edge pointnode-edge.prev \
    pointnode-edge-pnr2 pointnode-edge-update \
    pointnode-edge-remote pointnode-edge-remote-authority pointnode-edge-tunnel; do
    rm -f "${PN_PREFIX}/${program}" "${PN_PREFIX}/.${program}.new"
  done

  firewall_marker="${PN_CONFIG_DIR}/installer-firewall-ports"
  if [ -s "$firewall_marker" ]; then
    if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
      while IFS= read -r port; do
        case "$port" in ''|*[!0-9]*) continue ;; esac
        firewall-cmd --permanent --zone=public --remove-port="${port}/tcp" >/dev/null 2>&1 || true
      done < "$firewall_marker"
      firewall-cmd --reload >/dev/null 2>&1 || true
      rm -f "$firewall_marker"
    else
      warn "firewalld is unavailable; the UI ports recorded in ${firewall_marker} were not removed"
    fi
  fi
  if [ -f /etc/apache2/ports.conf.bak-pointnode ]; then
    mv /etc/apache2/ports.conf.bak-pointnode /etc/apache2/ports.conf
    command -v a2ensite >/dev/null 2>&1 && a2ensite 000-default >/dev/null 2>&1 || true
    systemctl reload-or-restart apache2 >/dev/null 2>&1 || true
  fi
  if [ -f /etc/avahi/avahi-daemon.conf.bak-pointnode ]; then
    mv /etc/avahi/avahi-daemon.conf.bak-pointnode /etc/avahi/avahi-daemon.conf
    systemctl restart avahi-daemon >/dev/null 2>&1 || true
  fi
  systemctl daemon-reload

  if [ "$PN_DELETE_DATA" = "1" ]; then
    if [ "$PN_ASSUME_YES" != "1" ]; then
      [ -t 0 ] || die_usage "--delete-data needs an interactive confirmation or --yes"
      info "This permanently deletes this gateway's identity, settings and stored readings."
      printf 'Type DELETE to continue: '
      IFS= read -r answer || true
      [ "${answer:-}" = "DELETE" ] || {
        info "Data was kept."
        PN_DELETE_DATA=0
      }
    fi
  fi

  if [ "$PN_DELETE_DATA" = "1" ]; then
    rm -rf -- \
      "${PN_STATE_DIR}" \
      "${PN_PNR2_STATE_DIR}" \
      "${PN_CONFIG_DIR}" \
      "${PN_REMOTE_STATE_DIR}" \
      "${PN_REMOTE_CONFIG_DIR}" \
      "${PN_TUNNEL_STATE_DIR}" \
      /var/lib/pointnode-edge-local-authority
    userdel "${PN_REMOTE_USER}" >/dev/null 2>&1 || true
    groupdel "${PN_REMOTE_USER}" >/dev/null 2>&1 || true
    userdel "${PN_USER}" >/dev/null 2>&1 || true
    groupdel "${PN_USER}" >/dev/null 2>&1 || true
    info "Programs, services, identity, settings and stored readings were removed."
  else
    info "Programs and services were removed. Identity, settings and stored readings were kept."
    info "Reinstalling later will use the preserved gateway state."
  fi
  # Releases are immutable program files, not gateway data. Remove the whole
  # PNR2 tree on uninstall; mutable identity, configuration, snapshots and
  # spool remain under /var/lib and follow --delete-data above.
  rm -rf -- /opt/pointnode-edge
}

# ── install ─────────────────────────────────────────────────────────────
install_account() {
  # The group is created explicitly, and first.
  #
  # The unit sets Group=pointnode-edge, and the only thing that used to
  # create that group was useradd's own USERGROUPS_ENAB behaviour -- a
  # /etc/login.defs setting that happens to be "yes" on Debian and is not
  # ours. Where it is not, useradd would put the account in a shared default
  # group, every install -g below would name a group that did not exist, and
  # the unit would refuse to start. That is the same defect the sysusers.d
  # note records for the account itself, one step over.
  if ! getent group "${PN_USER}" >/dev/null 2>&1; then
    groupadd --system "${PN_USER}" \
      || die "could not create the ${PN_USER} group"
  fi
  if ! id -u "${PN_USER}" >/dev/null 2>&1; then
    useradd --system --gid "${PN_USER}" --no-create-home --shell /usr/sbin/nologin \
      --home-dir "${PN_STATE_DIR}" "${PN_USER}" \
      || die "could not create the ${PN_USER} service account"
  fi
  install -d -m 0750 -o "${PN_USER}" -g "${PN_USER}" "${PN_STATE_DIR}"
  install -d -m 0750 -o root -g "${PN_USER}" "${PN_CONFIG_DIR}"

  # ── the remote-access sidecar's account, on the same terms ──────────────
  #
  # Created here even though nothing starts the remote units at boot, for the
  # same reason the gateway account is: the units are PLACED by this installer
  # (see install_unit), and a placed unit that names an account that does not
  # exist does not fail at install -- it fails at 216/GROUP or 226/NAMESPACE
  # the first time a commissioned gateway is granted a session, in a locked
  # cabinet, with the least possible information reaching anyone. Creating the
  # account and its two directories now is inert -- no [Install], no socket
  # started -- and turns that latent failure into a working boundary the day
  # the sidecar is commissioned. Bench-proven 2026-09-07: without these three
  # the local remote-approval writer cannot start at all.
  if ! getent group "${PN_REMOTE_USER}" >/dev/null 2>&1; then
    groupadd --system "${PN_REMOTE_USER}" \
      || die "could not create the ${PN_REMOTE_USER} group"
  fi
  if ! id -u "${PN_REMOTE_USER}" >/dev/null 2>&1; then
    useradd --system --gid "${PN_REMOTE_USER}" --no-create-home --shell /usr/sbin/nologin \
      --home-dir "${PN_REMOTE_STATE_DIR}" "${PN_REMOTE_USER}" \
      || die "could not create the ${PN_REMOTE_USER} service account"
  fi
  # Root-owned and not group-shared with the sidecar: it reaches this directory
  # only through systemd's credential snapshot, never through the filesystem.
  install -d -m 0700 -o root -g root "${PN_REMOTE_CONFIG_DIR}"
  # The root/commissioning trust domain for the final local remote decision.
  # tmpfiles.d recreates it every boot; creating it here makes the boundary
  # usable on first install without waiting for a reboot.
  install -d -m 0700 -o root -g root /var/lib/pointnode-edge-local-authority
}

install_binary() {
  work="$1"

  # Keep the binary being replaced.
  #
  # This script is the upgrade path as well as the install path, and it
  # overwrote the running binary with nothing kept. "Keep the previous
  # binary" is already the documented limit of rollback for a BYO install
  # (docs/hardware/README.md), and it had no implementation here -- an
  # upgrade that installed cleanly and then would not run left an appliance
  # in a locked panel with nothing to go back to.
  #
  # One copy, taken before the replacement, is the whole of it. The rollback
  # is then two commands an engineer can give over a telephone, and they are
  # printed at the end of this script. Copy-then-rename for the same reason
  # the install below does it: a truncated .prev is worse than none.
  #
  # It costs one binary's worth of space on /usr. On the smallest candidate
  # eMMC that is a fraction of a percent; a copy that cannot be made is
  # reported and does not stop the install, because refusing to upgrade a
  # gateway over a spare copy would be the wrong trade.
  if [ -f "${PN_PREFIX}/pointnode-edge" ]; then
    if cp -p "${PN_PREFIX}/pointnode-edge" "${PN_PREFIX}/.pointnode-edge.prev.new"; then
      mv -f "${PN_PREFIX}/.pointnode-edge.prev.new" "${PN_PREFIX}/pointnode-edge.prev"
    else
      rm -f "${PN_PREFIX}/.pointnode-edge.prev.new"
      warn "could not retain the previous binary; continuing without a rollback copy"
    fi
  fi

  # Install to a temporary name and rename, so a failure part-way cannot
  # leave a half-written binary where systemd will try to execute it.
  install -m 0755 -o root -g root "${work}/pointnode-edge" "${PN_PREFIX}/.pointnode-edge.new"
  mv -f "${PN_PREFIX}/.pointnode-edge.new" "${PN_PREFIX}/pointnode-edge"
  # Boot-relative microseconds at the moment the new binary landed. Used only as
  # a fallback proof that the running service was started from it (see
  # wait_for_service). /proc/uptime counts suspend and systemd's monotonic
  # clock does not, so this can only make the fallback stricter, never looser.
  PN_BINARY_SWAPPED_US="$(awk '{printf "%d", $1 * 1000000}' /proc/uptime 2>/dev/null || echo "")"

  # The remote-access helpers, which the units below invoke by absolute path.
  #
  # These were placed as UNITS and never as BINARIES: the release archive
  # built only cmd/pointnode-edge, so five units shipped pointing at three
  # files that did not exist. The gateway then reported "Remote support is
  # not enabled on this gateway build", which is true and says nothing about
  # a missing file -- and the capability it silently withheld is the one that
  # decides whether a configuration change needs a van.
  #
  # Absent from an older archive is tolerated, so this installer still works
  # against a release built before the fix. Present but unwritable is not.
  for helper in pointnode-edge-pnr2 pointnode-edge-update pointnode-edge-remote pointnode-edge-remote-authority pointnode-edge-tunnel; do
    if [ -f "${work}/${helper}" ]; then
      install -m 0755 -o root -g root "${work}/${helper}" "${PN_PREFIX}/.${helper}.new"
      mv -f "${PN_PREFIX}/.${helper}.new" "${PN_PREFIX}/${helper}"
    else
      die_install "release archive does not contain ${helper}"
    fi
  done

  # The third-party licence notices, beside the programs they describe.
  # Absent from a release built before they were added, and a failure to
  # place them is reported rather than fatal: the same text is compiled into
  # pointnode-edge (`pointnode-edge -third-party-notices`, and
  # /third-party-notices.txt on the local web interface), so refusing an
  # install over a documentation copy would be the wrong trade.
  if [ -f "${work}/THIRD_PARTY_NOTICES.txt" ]; then
    install -D -m 0644 -o root -g root "${work}/THIRD_PARTY_NOTICES.txt" \
      "${PN_DOC_DIR}/THIRD_PARTY_NOTICES.txt" \
      || warn "could not place the third-party notices in ${PN_DOC_DIR}"
  fi
}

install_unit() {
  work="$1"

  install_unit_file "$work" pointnode-edge.service
  install_unit_file "$work" pointnode-edge-update.service
  install_unit_file "$work" pointnode-edge-update.timer
  install_unit_file "$work" pointnode-edge-pnr2-activate.path
  install_unit_file "$work" pointnode-edge-pnr2-activate.service
  install_unit_file "$work" pointnode-edge-pnr2-recover.service

  # ── the remote-access units ───────────────────────────────────────────
  #
  # PLACED HERE. Three of them are then enabled by
  # activate_remote_consent_boundary -- the local consent socket and the
  # consent-gated reconciler that starts the sidecar ONLY when the site has
  # said yes. The sidecar and tunnel units below are never enabled directly.
  # The history below is why that split exists.
  #
  # Until now the installer left these behind, so a gateway that Cloud later
  # granted a session to had nowhere to run it, and the operator learned that
  # from a missing-unit error rather than from anything that had told them.
  # Placing the files is what makes remote access possible without a second
  # download onto an appliance in a cabinet.
  #
  # Enabling them is a different act, and this installer does not perform it:
  #
  #  * None of the five carries an [Install] section, deliberately -- each
  #    file says so in its own footer and packaging/systemd's tests fail if one
  #    ever gains one. `systemctl enable` on a unit with no [Install] does
  #    nothing useful, so enabling here would mean editing reviewed units, and
  #    that is a review decision rather than an installer decision.
  #  * The sidecar is dormant by design: it starts when a commissioned gateway
  #    is granted a session, not at boot. A tunnel process running on every
  #    appliance in the field, waiting for work that may never arrive, is
  #    exactly the standing exposure the design refused.
  #  * The socket and the path unit have activation of their own and must not
  #    be started independently. The .socket is a root-helper boundary that
  #    should exist only once the sidecar is commissioned, and the .path unit
  #    is PartOf the sidecar -- systemd starts it with the sidecar and stops it
  #    with it. Enabling either separately would create a live boundary around
  #    a service nobody has commissioned.
  #
  # The same "die if the archive lacks it" discipline applies to all six. A
  # release that has lost a unit file is a broken release, and installing four
  # fifths of a remote-access story quietly is how the gap being closed here
  # was created in the first place.
  install_unit_file "$work" pointnode-edge-remote.service
  install_unit_file "$work" pointnode-edge-remote-authority@.service
  install_unit_file "$work" pointnode-edge-remote-authority.socket
  install_unit_file "$work" pointnode-edge-remote-credential.path
  install_unit_file "$work" pointnode-edge-remote-credential.service

  # ── the privileged tunnel boundary ────────────────────────────────────
  #
  # The sidecar above holds no capability at all, on purpose, so it cannot
  # bring a tunnel up. These two are the confined helper that can: a 0600
  # socket only the sidecar account may open, and a service that holds
  # CAP_NET_ADMIN and nothing else it does not need.
  #
  # Placed on the same terms as the five above and for the same reason -- a
  # gateway that Cloud later grants a tunnel to must not learn about a missing
  # unit from an error. Neither carries an [Install] section either, so neither
  # starts at boot and neither can be enabled without editing a reviewed unit.
  install_unit_file "$work" pointnode-edge-tunnel.socket
  install_unit_file "$work" pointnode-edge-tunnel.service
}

# ── the site's consent to remote support ───────────────────────────────
#
# A freshly installed gateway could not record consent at all. The units were
# placed but the local authority socket -- the ONLY way the site's decision is
# written -- was never started, so the local interface could only say remote
# support was unavailable, and turning it on needed somebody to type systemctl
# commands into a gateway in a cabinet. Nothing here enables remote access.
# It makes it possible for a person AT THE SITE to enable it:
#
#   * pointnode-edge-remote-authority.socket -- the root-owned writer of the
#     site's decision, reachable only by the gateway service's own account,
#     which relays it only from the commissioning connection, the gateway
#     itself, or an administrator on the site network who re-enters their
#     password. Never from Pointnode Cloud or through a Pointnode carrier.
#   * pointnode-edge-remote-credential.path + .service -- the consent-gated
#     reconciler. It starts the sidecar (and with it the tunnel socket) when the
#     site's root-owned decision says yes, and stops both when it says anything
#     else. It is the single, idempotent activation step; nobody runs it by hand.
#
# The sidecar and tunnel units are still never enabled here: they have no
# [Install] section and run only while the site's consent stands.
#
# Warns rather than dies: a gateway whose remote-support boundary could not be
# started still acquires and delivers telemetry, and refusing the whole install
# would take that away to protect an optional feature. The closing report says
# exactly what is not running.
PN_REMOTE_AUTHORITY_READY=0
activate_remote_consent_boundary() {
  if systemctl enable --now pointnode-edge-remote-authority.socket >/dev/null 2>&1; then
    PN_REMOTE_AUTHORITY_READY=1
  else
    warn "the local remote-support consent service could not be started; remote support cannot be turned on until it is"
  fi
  systemctl enable --now pointnode-edge-remote-credential.path >/dev/null 2>&1 \
    || warn "the remote-support consent watcher could not be started"
  systemctl enable pointnode-edge-remote-credential.service >/dev/null 2>&1 \
    || warn "the remote-support consent reconciler could not be enabled at boot"
  reconcile_remote_consent
}

# Apply the site's current decision now. Skipped while a tunnel is live: the
# reconciler restarts a consenting sidecar, and a re-install must not cut off an
# engineer mid-session. The boot-time run picks it up instead.
reconcile_remote_consent() {
  if systemctl is-active --quiet pointnode-edge-tunnel.service 2>/dev/null; then
    PN_REMOTE_RESTART_PENDING=1
    return 0
  fi
  systemctl start pointnode-edge-remote-credential.service >/dev/null 2>&1 \
    || warn "the site's remote-support decision could not be applied now; it will be at the next boot"
}

# The remote sidecar authenticates Pointnode (and the remote-support broker) with
# its OWN root-owned trust snapshot, /etc/pointnode-edge-remote/server-trust.pem,
# never with anything the acquisition account could rewrite underneath it. That
# file was produced only by the manual install-remote-access.sh, so on a gateway
# installed by this script the sidecar refused to start with "relay trust
# unreadable" and remote support could never work.
#
# It is built from the enrolled identity: the server trust Pointnode issued at
# enrolment (the public roots for app.pointnode.io and the relay) followed by the
# enrolment CA (which signs the remote-support broker's certificate). Strictly
# certificates: anything else in either file is refused rather than copied. An
# existing file is never replaced -- an operator-reviewed bundle wins.
provision_remote_relay_trust() {
  relay_trust="${PN_REMOTE_CONFIG_DIR}/server-trust.pem"
  if [ -f "$relay_trust" ]; then
    return 0
  fi
  # The enrolled identity lives under state/, beside the device bundle that
  # the enrolment check above and the sidecar's LoadCredential both read.
  # This used to read ${PN_STATE_DIR}/identity, which never exists, so every
  # install reported "Pointnode trust: not yet" and the sidecar could not start.
  identity_trust="${PN_STATE_DIR}/state/identity/server-trust.pem"
  identity_ca="${PN_STATE_DIR}/state/identity/issuing-ca.pem"
  if [ ! -s "$identity_trust" ] || [ ! -s "$identity_ca" ]; then
    # Not enrolled yet. Said in the closing report, not here.
    return 0
  fi
  install -d -m 0700 -o root -g root "${PN_REMOTE_CONFIG_DIR}"
  trust_candidate="$(mktemp "${PN_REMOTE_CONFIG_DIR}/.server-trust.pem.XXXXXX")" || {
    warn "could not stage the remote-support trust file"
    return 0
  }
  if strict_certificates "$identity_trust" > "$trust_candidate" \
     && strict_certificates "$identity_ca" >> "$trust_candidate"; then
    chown root:root "$trust_candidate"
    chmod 0600 "$trust_candidate"
    mv -f "$trust_candidate" "$relay_trust"
    info "Provisioned the remote-support trust ${relay_trust} from this gateway's enrolled identity."
    reconcile_remote_consent
  else
    rm -f "$trust_candidate"
    warn "the enrolled identity's trust files are not certificate-only PEM; remote-support trust was NOT provisioned"
  fi
}

# ── identity-bound remote state after a re-enrolment ────────────────────
#
# Three replay floors on this appliance pin the device principal they were
# written for, trust on first use:
#
#   ${PN_TUNNEL_STATE_DIR}/target-registry-floor.json      (tunnel helper)
#   ${PN_REMOTE_STATE_DIR}/remote-target-registry-floor.json (sidecar registry)
#   ${PN_REMOTE_STATE_DIR}/remote-session-floor.json         (sidecar sessions)
#
# Each refuses, for ever, any document naming another principal, and none of
# them is ever reset by the code that reads it -- correctly: a floor that a
# process could lower is not a rollback defence. But a gateway that is
# re-enrolled gets a NEW principal (pnd-<new uuid>), and Cloud's registry and
# leases for it start again at revision 1. Against a floor still naming the old
# principal at revision 4 every one is refused -- the tunnel helper answers
# no_registry ("the accepted target registry could not be read") -- and nothing
# on the appliance can ever recover it. That stranded a RevPi on 2026-10-10:
# uninstall --delete-data removed the sidecar's state but not the helper's, and
# the new identity met the old floor.
#
# Moving such a floor aside does not weaken the rollback defence for the
# identity in force, and the reasoning is the whole justification:
#
#   * Only a floor whose recorded principal DIFFERS from the enrolled one is
#     touched. A gateway whose identity has not changed keeps every floor
#     exactly as it is, on every run, however often this installer runs.
#   * A floor for principal A protects nothing for principal B. Every document
#     these floors guard is signed for one named principal and refused by the
#     reader when it names another, so no registry or lease issued for A can be
#     replayed against B whether A's floor is present or not. What B needs is a
#     floor of its own, which its readers pin on first use exactly as A's did.
#     internal/replayfloor names re-enrolment as the one occasion on which
#     clearing a floor is acceptable.
#   * The principal compared against is the leaf certificate CN of the enrolled
#     bundle -- the identity the sidecar is started with -- read with openssl,
#     which this installer already requires.
#   * Nothing is deleted. Each file is renamed to
#     <name>.superseded-<old principal>-<UTC timestamp> in its own directory,
#     where no reader looks for it and an engineer can.
#
# NOT touched: remote-trust-floor.json, remote-trust-keyset.jws and
# remote-trust-roots.json. They name no principal: the keyset version floor and
# the cumulative revocation union are fleet-wide, and discarding them would let
# a re-enrolled gateway accept a keyset or a key the fleet has since revoked.
# Also untouched: the audit log (history, not authority) and the site's local
# off switch. A floor whose principal cannot be read is left alone and
# reported: it is corrupt or not ours, and resetting what cannot be read is
# exactly what a replay would arrange.
#
# The sidecar's registry floor moves together with the registry and envelope it
# vouches for: internal/targetregistry treats a stored registry with no floor
# behind it as unreadable state (deny-all until repaired), so moving the floor
# alone would strand the sidecar exactly as the helper was stranded.
#
# A live tunnel is never disturbed: while the helper is active this defers, and
# says so. Otherwise the sidecar is stopped while its files move, and the
# site's own consent decides whether it starts again.

# Print the enrolled leaf certificate's CN when it is a device principal.
enrolled_device_principal() {
  bundle="${PN_STATE_DIR}/state/identity/device.bundle.pem"
  [ -s "$bundle" ] || return 1
  principal="$(leaf_certificate_cn "$bundle")"
  device_principal_shape "$principal" || return 1
  printf '%s' "$principal"
}

# The first CERTIFICATE block is the leaf (internal/identity SplitBundle).
# Only certificate lines reach openssl; the private key never leaves the file.
leaf_certificate_cn() {
  awk '
      { sub(/\r$/, "") }
      $0 == "-----BEGIN CERTIFICATE-----" { inside = 1 }
      inside { print }
      inside && $0 == "-----END CERTIFICATE-----" { exit }
    ' "$1" \
    | openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null \
    | sed -n 's/^subject= *\(.*,\)\{0,1\}CN=\(pnd-[0-9a-f-]*\)\(,.*\)\{0,1\}$/\2/p'
}

# The single principal shape Cloud assigns (identity.ValidDevicePrincipal).
device_principal_shape() {
  printf '%s' "$1" | grep -Eqx 'pnd-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
}

# Print the device_principal a floor records, or nothing.
floor_device_principal() {
  [ -f "$1" ] || return 1
  recorded="$(sed -n 's/.*"device_principal"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -n 1)"
  device_principal_shape "$recorded" || return 1
  printf '%s' "$recorded"
}

# Decide one floor: 0 = superseded (prints its principal), 1 = keep.
floor_is_superseded() {
  floor="$1"
  enrolled="$2"
  [ -e "$floor" ] || return 1
  if ! recorded="$(floor_device_principal "$floor")"; then
    warn "${floor} names no readable device principal; it was left in place"
    return 1
  fi
  [ "$recorded" != "$enrolled" ] || return 1
  printf '%s' "$recorded"
}

retire_superseded_remote_state() {
  enrolled="$(enrolled_device_principal)" || return 0

  helper_floor="${PN_TUNNEL_STATE_DIR}/target-registry-floor.json"
  registry_floor="${PN_REMOTE_STATE_DIR}/remote-target-registry-floor.json"
  session_floor="${PN_REMOTE_STATE_DIR}/remote-session-floor.json"

  helper_old="$(floor_is_superseded "$helper_floor" "$enrolled")" || helper_old=""
  registry_old="$(floor_is_superseded "$registry_floor" "$enrolled")" || registry_old=""
  session_old="$(floor_is_superseded "$session_floor" "$enrolled")" || session_old=""
  [ -n "${helper_old}${registry_old}${session_old}" ] || return 0

  if systemctl is-active --quiet pointnode-edge-tunnel.service 2>/dev/null; then
    warn "remote-support state from a previous identity is still on this gateway,"
    warn "but a tunnel is active; re-run this installer once it has ended"
    return 0
  fi
  systemctl stop pointnode-edge-remote.service >/dev/null 2>&1 || true

  stamp="$(date -u +%Y%m%dT%H%M%SZ)"
  if [ -n "$helper_old" ]; then
    supersede_file "$helper_floor" "$helper_old" "$stamp"
  fi
  if [ -n "$registry_old" ]; then
    supersede_file "$registry_floor" "$registry_old" "$stamp"
    supersede_file "${PN_REMOTE_STATE_DIR}/remote-target-registry.json" "$registry_old" "$stamp"
    supersede_file "${PN_REMOTE_STATE_DIR}/remote-target-registry.jws" "$registry_old" "$stamp"
  fi
  if [ -n "$session_old" ]; then
    supersede_file "$session_floor" "$session_old" "$stamp"
  fi
  info "This gateway is now ${enrolled}; remote-support state pinned to its previous identity was moved aside, not deleted."
  reconcile_remote_consent
}

supersede_file() {
  [ -e "$1" ] || return 0
  if mv -f -- "$1" "${1}.superseded-${2}-${3}"; then
    info "Moved ${1} aside to ${1}.superseded-${2}-${3} (it was pinned to ${2})."
  else
    warn "could not move ${1} aside; remote support may refuse until it is"
  fi
}

# Emit certificate blocks only. Blank separator lines are dropped; labels,
# prose, private keys, malformed boundaries and blank lines inside a
# certificate make the whole input a refusal. The same rule as
# install-remote-access.sh, so the two ways of producing this file agree.
strict_certificates() {
  awk '
    BEGIN { inside = 0; count = 0; body_lines = 0; bad = 0 }
    { sub(/\r$/, "") }
    $0 == "-----BEGIN CERTIFICATE-----" {
      if (inside) bad = 1
      inside = 1
      count++
      body_lines = 0
      print
      next
    }
    $0 == "-----END CERTIFICATE-----" {
      if (!inside || body_lines == 0) bad = 1
      print
      inside = 0
      next
    }
    inside && $0 ~ /^[A-Za-z0-9+\/=]+$/ { body_lines++; print; next }
    !inside && $0 ~ /^[[:space:]]*$/ { next }
    { bad = 1 }
    END {
      if (bad || inside || count == 0) exit 1
    }
  ' "$1"
}

# ── the front-panel status LEDs ─────────────────────────────────────────
#
# The gateway runs unprivileged and the LED nodes are root:root 0644, so
# without this rule the panel is dark on every appliance -- including the lamp
# that indicates a remote session is in progress. See packaging/udev for the
# argument about why a udev rule and not a chmod here: sysfs is repopulated by
# the kernel on every boot, so anything this script did directly would be
# correct exactly once.
#
# Reloaded and triggered rather than left for the next reboot, because the
# requirement is ONE install with no per-device commands. An operator who has
# just watched the installer finish should see the lamp test, not discover it
# after the next power cut.
#
# WARNS RATHER THAN DIES, unlike the units. A gateway with a dark panel still
# acquires, buffers and delivers; refusing the whole install over a status
# lamp would take a plant's telemetry away to protect a cabinet light. An
# archive built before this rule existed is tolerated for the same reason.
install_udev() {
  udev_work="$1"
  udev_rule=99-pointnode-edge-leds.rules
  udev_src="${udev_work}/packaging/udev/${udev_rule}"

  if [ ! -f "$udev_src" ]; then
    warn "${udev_rule} is not in this release; the front-panel status LEDs will stay dark"
    return 0
  fi

  install -d -m 0755 -o root -g root /etc/udev/rules.d
  install -m 0644 -o root -g root "$udev_src" "/etc/udev/rules.d/${udev_rule}"

  if ! command -v udevadm >/dev/null 2>&1; then
    warn "udevadm not found; the status LEDs will start working after the next reboot"
    return 0
  fi
  # Both are best effort. A container without a udev daemon fails these and is
  # not a machine with status LEDs anyway.
  udevadm control --reload-rules >/dev/null 2>&1 \
    || warn "could not reload udev rules; the status LEDs will start working after the next reboot"
  udevadm trigger --subsystem-match=leds >/dev/null 2>&1 \
    || warn "could not re-apply udev rules to the LED devices; a reboot will do it"
}

install_runtime_rules() {
  rule_src="${1}/packaging/tmpfiles.d/pointnode-edge-remote-status.conf"
  [ -f "$rule_src" ] || return 0
  install -d -m 0755 -o root -g root /etc/tmpfiles.d
  install -m 0644 -o root -g root "$rule_src" /etc/tmpfiles.d/pointnode-edge-remote-status.conf
  systemd-tmpfiles --create /etc/tmpfiles.d/pointnode-edge-remote-status.conf \
    || die_install "could not create the remote status and update-inhibitor runtime paths"
}

install_unit_file() {
  unit_work="$1"
  unit_name="$2"
  unit_src="${unit_work}/packaging/systemd/${unit_name}"

  [ -f "$unit_src" ] \
    || die "release archive does not contain the systemd unit ${unit_name}"
  install -m 0644 -o root -g root "$unit_src" "/etc/systemd/system/${unit_name}"
}

# Choose the tunnel interface roles when the operator has not named them.
#
# WAN = the default-route interface. That is the internet-facing side and the
# one that MUST NOT forward, and it is the half the machine can answer reliably:
# whichever NIC carries the default route is how it reaches the internet.
#
# PLANT = the engineering-network side, which has no signal of its own ("the
# PLCs are here" is a deployment fact, not something readable from the NIC). On
# the RevPi the engineering port is lan1, while the internet side may be wan,
# lan0 or wlan0 -- so lan1 is preferred when it is a live wired candidate. Off a
# RevPi, or with lan1 absent, it is auto-chosen only when there is exactly one
# other real, wired, up interface. Otherwise we refuse to guess -- mislabelling
# the forwarding side is a safety fault, so a wrong guess is worse than none.
# Actual routing still wins: whatever carries the default route is WAN and is
# never offered as plant, even if that is lan1.
detect_tunnel_interfaces() {
  [ -z "${PN_PLANT_INTERFACE}" ] && [ -z "${PN_WAN_INTERFACE}" ] || return 0

  detected_wan="$(ip route show default 2>/dev/null \
    | grep -o 'dev [^ ]*' | awk '{print $2}' | head -n 1)"
  if [ -z "$detected_wan" ]; then
    info "Remote-support network roles not auto-set: no default route identifies the internet-facing port. Browser and engineering connections will still work; a full network tunnel needs PN_PLANT_INTERFACE and PN_WAN_INTERFACE set when re-running this installer."
    return 0
  fi

  plant_candidates=""
  for iface_path in /sys/class/net/*; do
    iface="$(basename "$iface_path")"
    [ "$iface" = "lo" ] && continue
    [ "$iface" = "$detected_wan" ] && continue
    [ -e "${iface_path}/device" ] || continue    # skip virtual: bridges, veth, docker
    [ -e "${iface_path}/wireless" ] && continue   # the plant side is wired
    [ "$(cat "${iface_path}/operstate" 2>/dev/null)" = "up" ] || continue
    plant_candidates="${plant_candidates} ${iface}"
  done

  # shellcheck disable=SC2086
  set -- $plant_candidates
  plant_count=$#

  # Prefer lan1 (the RevPi engineering port). When more than one wired NIC is up
  # -- lan0 AND lan1, say -- "the other one" beside WAN would be a coin toss, and
  # lan1 is the plant side. If real routing has put the default on lan1 it was
  # treated as WAN above and is not a candidate here, so routing still wins.
  chosen_plant=""
  for candidate in $plant_candidates; do
    [ "$candidate" = "lan1" ] && chosen_plant="lan1"
  done
  [ -n "$chosen_plant" ] || { [ "$plant_count" -eq 1 ] && chosen_plant="$1"; }

  if [ -n "$chosen_plant" ]; then
    PN_PLANT_INTERFACE="$chosen_plant"
    PN_WAN_INTERFACE="$detected_wan"
    info "Remote-access interfaces detected: plant=${PN_PLANT_INTERFACE}, wan=${PN_WAN_INTERFACE} (override with PN_PLANT_INTERFACE / PN_WAN_INTERFACE)."
  else
    info "Remote-support network roles not auto-set: ${plant_count} candidate plant ports beside ${detected_wan}. Browser and engineering connections will still work; a full network tunnel needs PN_PLANT_INTERFACE and PN_WAN_INTERFACE set when re-running this installer."
  fi
}

# The live telemetry block every new production install carries.
#
# Live is the lossy "somebody has a dashboard open" path. It publishes only
# while Cloud holds a SIGNED lease naming signals on this gateway, so being
# switched on here sends nothing until Cloud asks. Three gates move together
# (live, lease delivery, the HTTPS carrier) because a partial set is a startup
# error, not a half-on gateway. HTTPS rather than mqtt-v5 because Cloud's live
# intake is the signed POST route; the MQTT topic has no ingest parser.
#
# The lease-verification keys come from Cloud (lease_keys.source: cloud) over
# the same signed mTLS channel as the leases, and are saved under the state
# directory -- the only place the hardened unit may write. No key file is
# copied by hand. Development installs do not exist in this script; a
# development gateway keeps live on the in-process loopback.
live_config_block() {
  cat <<YAML
# Live telemetry: current values for a dashboard that is open right now.
# Publishes nothing without a signed lease from Pointnode Cloud.
live:
  enabled: true
  transport: https
  lease_keys:
    source: cloud
  lease_keys_path: "${PN_STATE_DIR}/live-lease-keys.json"
  lease_delivery:
    enabled: true
    base_url: "${PN_CLOUD_URL}"
  https:
    enabled: true
    base_url: "${PN_CLOUD_URL}"
YAML
}

# add_live_block_if_absent brings an EXISTING gateway up to the same live
# settings a new install gets, and does so only when it is unambiguous.
#
# The rule for an existing device.yaml is that this installer never edits what
# is there. Appending a top-level block that does not exist is not an edit of
# anything an operator wrote, and only that is done:
#
#   - a file that already has a top-level live: key is left exactly alone,
#     whatever it says (somebody decided);
#   - a development-mode file is left alone (production live is refused there);
#   - an environment file that sets any PNE_LIVE_ variable is left alone,
#     because environment overrides the file and a half-overridden gate set
#     would stop the service from starting;
#   - and the result is validated by the installed binary BEFORE it replaces
#     the original. If it does not validate, the original stays and the
#     operator is told.
add_live_block_if_absent() {
  cfg="${PN_CONFIG_DIR}/device.yaml"
  if grep -q '^live:' "$cfg" 2>/dev/null; then
    info "Keeping the existing live telemetry settings"
    return 0
  fi
  if grep -Eq '^mode:[[:space:]]*["'"'"']?development' "$cfg" 2>/dev/null; then
    info "Development configuration: live telemetry stays on the local loopback"
    return 0
  fi
  if [ -f "${PN_CONFIG_DIR}/environment" ] && \
     grep -Eq '^[[:space:]]*(export[[:space:]]+)?PNE_LIVE_' "${PN_CONFIG_DIR}/environment"; then
    warn "Live telemetry settings were NOT added: ${PN_CONFIG_DIR}/environment sets PNE_LIVE_ variables, which override device.yaml."
    return 0
  fi
  candidate="$(mktemp "${PN_CONFIG_DIR}/.device.yaml.live.XXXXXX")" || {
    warn "Live telemetry settings were NOT added: could not stage a copy of device.yaml."
    return 0
  }
  cp "$cfg" "$candidate"
  if [ -s "$candidate" ] && [ -n "$(tail -c 1 "$candidate")" ]; then
    printf '\n' >> "$candidate"
  fi
  { printf '\n'; live_config_block; } >> "$candidate"
  chown root:"${PN_USER}" "$candidate"
  chmod 0640 "$candidate"
  if "${PN_PREFIX}/pointnode-edge" -check-config -config "$candidate" >/dev/null 2>&1; then
    mv -f "$candidate" "$cfg"
    info "Added live telemetry settings to ${cfg} (lease keys are fetched from Pointnode Cloud)"
  else
    rm -f "$candidate"
    warn "Live telemetry settings were NOT added: the result did not validate. ${cfg} is unchanged."
  fi
}

# Readings are delivered to Pointnode Cloud over signed HTTPS, up to 100 per
# request, each carrying the time it was recorded. After an outage the
# backlog drains in bulk -- Cloud paces a gateway at about 20 readings a
# second -- instead of one round trip per reading. MQTT v5 remains a
# supported carrier for an operator who chooses it.
history_config_block() {
  cat <<YAML
history:
  enabled: true
  delivery:
    enabled: true
    transport: http
    base_url: "${PN_CLOUD_URL}"
    batch_size: 100
    poll_interval: 5s
    attempt_timeout: 30s
    cleanup_timeout: 10s
YAML
}

# Earlier installers wrote MQTT v5 as their own default historian carrier.
# Exactly that generated block -- every line as this installer wrote it, the
# broker address being the only line allowed to differ -- is moved to bulk
# HTTPS. Any other spelling is an operator's choice and is left alone. The
# edit is validated by the installed binary before it replaces the original.
switch_history_to_http_if_installer_default() {
  cfg="${PN_CONFIG_DIR}/device.yaml"
  grep -qx '    transport: mqtt-v5' "$cfg" 2>/dev/null || return 0
  candidate="$(mktemp "${PN_CONFIG_DIR}/.device.yaml.history.XXXXXX")" || {
    warn "Historian delivery was NOT moved to HTTPS: could not stage a copy of device.yaml."
    return 0
  }
  PN_HISTORY_REPLACEMENT="$(history_config_block)" awk '
    BEGIN {
      n = split("history:|  enabled: true|  delivery:|    enabled: true|    transport: mqtt-v5|    poll_interval: 5s|    attempt_timeout: 15s|    cleanup_timeout: 10s|    mqtt:|SERVER|      keep_alive: 60s|      connect_timeout: 15s|      publish_timeout: 15s|      close_timeout: 5s|      session_expiry: 1h", want, "|")
    }
    function matches(i, line) {
      if (want[i] == "SERVER") return line ~ /^      server_url: "tls:\/\/[^"]*"$/
      return line == want[i]
    }
    done == 0 && pos == 0 && $0 == want[1] { pos = 1; held[1] = $0; next }
    pos > 0 {
      if (matches(pos + 1, $0)) {
        pos++; held[pos] = $0
        if (pos == n) { print ENVIRON["PN_HISTORY_REPLACEMENT"]; done = 1; pos = 0 }
        next
      }
      for (i = 1; i <= pos; i++) print held[i]
      pos = 0
    }
    { print }
    END {
      for (i = 1; i <= pos; i++) print held[i]
      exit done ? 0 : 1
    }
  ' "$cfg" > "$candidate" || { rm -f "$candidate"; return 0; }
  chown root:"${PN_USER}" "$candidate"
  chmod 0640 "$candidate"
  if "${PN_PREFIX}/pointnode-edge" -check-config -config "$candidate" >/dev/null 2>&1; then
    mv -f "$candidate" "$cfg"
    info "Moved historian delivery to bulk HTTPS in ${cfg} (readings upload up to 100 per request)"
  else
    rm -f "$candidate"
    warn "Historian delivery was NOT moved to HTTPS: the result did not validate. ${cfg} is unchanged."
  fi
}

# Automatic updates used to be written OFF by this installer, as its own
# default rather than as anybody's decision. Exactly that generated stanza --
# the three comment lines this installer wrote above it included -- is
# switched on; any other spelling, including a hand-written
# `enabled: false`, is an operator's opt-out and is left alone. The edit is
# validated by the installed binary before it replaces the original.
enable_updates_if_installer_default() {
  cfg="${PN_CONFIG_DIR}/device.yaml"
  grep -qx '# this setting expresses operator intent; it does not bypass that check.' "$cfg" 2>/dev/null || return 0
  candidate="$(mktemp "${PN_CONFIG_DIR}/.device.yaml.update.XXXXXX")" || {
    warn "Automatic updates were NOT switched on: could not stage a copy of device.yaml."
    return 0
  }
  awk '
    state == 0 && $0 == "# this setting expresses operator intent; it does not bypass that check." { state = 1; print; next }
    state == 1 && $0 == "update:" { state = 2; print; next }
    state == 2 && $0 == "  automatic:" { state = 3; print; next }
    state == 3 && $0 == "    enabled: false" { state = 4; print "    enabled: true"; next }
    state >= 1 && state <= 3 { state = 0 }
    { print }
    END { exit state == 4 ? 0 : 1 }
  ' "$cfg" > "$candidate" || { rm -f "$candidate"; return 0; }
  chown root:"${PN_USER}" "$candidate"
  chmod 0640 "$candidate"
  if "${PN_PREFIX}/pointnode-edge" -check-config -config "$candidate" >/dev/null 2>&1; then
    mv -f "$candidate" "$cfg"
    info "Switched on signed automatic updates in ${cfg} (set update.automatic.enabled: false to opt out)"
  else
    rm -f "$candidate"
    warn "Automatic updates were NOT switched on: the result did not validate. ${cfg} is unchanged."
  fi
}

write_config() {
  if [ -f "${PN_CONFIG_DIR}/device.yaml" ]; then
    info "Keeping the existing ${PN_CONFIG_DIR}/device.yaml"
    # An operator who passes PN_UI_PORT on a re-install is asking for a change
    # this function does not make: it never rewrites an existing file, because
    # that would discard hand edits. Saying so is the whole fix -- the silent
    # version was only discovered when the service failed to bind and the
    # override appeared to have done nothing.
    existing_ui_port="$(sed -n 's/^  port: \([0-9][0-9]*\)$/\1/p' "${PN_CONFIG_DIR}/device.yaml" | head -n 1)"
    if [ -n "$existing_ui_port" ] && [ "$existing_ui_port" != "${PN_UI_PORT}" ]; then
      warn "PN_UI_PORT=${PN_UI_PORT} was NOT applied: ${PN_CONFIG_DIR}/device.yaml already serves ${existing_ui_port}. Edit http.port and http.redirect_port there, then restart ${PN_SERVICE}."
    fi
    # The same rule applied to a setting that did not exist when most of the
    # fleet was installed. Without change_notifications the gateway finds an
    # operator's edit only on its next scheduled poll, and the key defaults to
    # false in the binary -- so an appliance upgraded by re-running this
    # installer stays on the slow path and nothing says so. This function will
    # not rewrite a file it did not write, which leaves exactly one honest
    # option: print the line to add. Silence here is the defect the PN_UI_PORT
    # warning above was written to stop, in a second place.
    if ! grep -q '^[[:space:]]*change_notifications:' "${PN_CONFIG_DIR}/device.yaml" 2>/dev/null; then
      warn "This gateway will still find configuration changes only by polling. Add 'change_notifications: true' under cloud.managed_config in ${PN_CONFIG_DIR}/device.yaml (and lower poll_interval to 30s), then restart ${PN_SERVICE}."
    fi
    add_live_block_if_absent
    enable_updates_if_installer_default
    switch_history_to_http_if_installer_default
    return
  fi
  umask 027

  detect_tunnel_interfaces

  # The tunnel interface map, written CONFIG-ONLY. This declares which NIC is
  # the plant side (the return path forwards over it) and which is
  # internet-facing (it must never forward). It does not enable remote access:
  # remote support stays off until somebody at the site turns it on in the
  # local interface. This only means that when they do, no interface map has to
  # be hand-edited onto the appliance. Both roles or neither, and they must differ -- the same
  # rule config.validateRemoteTunnel enforces, checked here so a typo fails the
  # install rather than the first tunnel.
  PN_REMOTE_TUNNEL_BLOCK=""
  if [ -n "${PN_PLANT_INTERFACE}" ] || [ -n "${PN_WAN_INTERFACE}" ]; then
    [ -n "${PN_PLANT_INTERFACE}" ] && [ -n "${PN_WAN_INTERFACE}" ] || \
      die "remote tunnel needs BOTH PN_PLANT_INTERFACE and PN_WAN_INTERFACE, or neither"
    [ "${PN_PLANT_INTERFACE}" != "${PN_WAN_INTERFACE}" ] || \
      die "PN_PLANT_INTERFACE and PN_WAN_INTERFACE must name different interfaces"
    PN_REMOTE_TUNNEL_BLOCK="
# Remote-access tunnel interface roles, provisioned by the installer. Which NIC
# forwards (plant) and which must never (internet-facing). Declaring them does
# NOT enable remote access; that is the site's decision, made in the local UI.
remote:
  tunnel:
    plant_interface: \"${PN_PLANT_INTERFACE}\"
    wan_interface: \"${PN_WAN_INTERFACE}\""
  fi

  cat > "${PN_CONFIG_DIR}/device.yaml" <<YAML
# Written by the Pointnode installer. Safe to edit, back up and
# version-control: it deliberately contains no credential of any kind.
cloud:
  # A managed gateway publishes under the pnd- device principal it is given
  # at enrolment, so it needs no legacy asset-type topic prefix. It does need
  # a transport and a broker: without them the service refuses to start, and
  # "simulator" is refused outright in production mode because a gateway
  # quietly delivering into a local simulator looks healthy while sending
  # nothing anywhere.
  transport: mqtt
  broker: "${PN_BROKER}"
  port: ${PN_BROKER_PORT}
  tls: true

  identity:
    enrolment:
      enabled: true
      base_url: "${PN_CLOUD_URL}"
      attempt_timeout: 30s

    # The certificate this gateway authenticates with expires. Without this
    # line the renewal loop starts, logs "certificate renewal is not
    # configured; the credential will not renew automatically", and stops --
    # and ninety days later the gateway cannot reach the broker or Cloud at
    # all. It goes silent, buffers until the disk fills, and needs a site
    # visit. Enrolled a site in one afternoon? Then they all fail in the same
    # afternoon, three months later.
    #
    # A full endpoint rather than a base URL, because that is what this
    # setting is: the client pins the path it will accept from the enrolment
    # response rather than following whatever comes back, so the two must
    # agree exactly. /v1/device-certificates/renew is that path.
    #
    # Threshold and cadence are deliberately absent. The defaults -- renew
    # with a third of the certificate's life left, floor of thirty days,
    # check daily, with a per-gateway offset so a site does not renew in
    # lockstep -- are the ones the product wants, and a number written here
    # is a number nobody revisits when the certificate lifetime changes.
    renew_url: "${PN_CLOUD_URL%/}/v1/device-certificates/renew"

  # Cloud owns the configuration. Without this a gateway enrols, gets a
  # certificate, and then runs whatever was set up on the device -- nothing an
  # operator edits in Pointnode ever reaches it, and the connection page shows
  # a configuration the machine is not running.
  #
  # change_notifications is what makes an operator's edit land in SECONDS
  # rather than at the end of the next poll. It subscribes to exactly one
  # topic, pointnode/v1/devices/<this device>/config-changed, and the ONLY
  # thing an arriving message does is wake the poller that was going to run
  # anyway. It carries no configuration and no authority: the payload is
  # ignored, the gateway still fetches the signed document over HTTPS and
  # still verifies it, and the broker rule only ever grants this device a
  # SUBSCRIBE on its own topic -- it may never publish there. So the worst a
  # broker or a forged message can do is make a gateway ask Pointnode for its
  # own configuration slightly early, which is a thing it does on a timer.
  #
  # Written true here because the setting defaults to FALSE in the binary
  # (internal/config: "off by default until the matching broker rule and
  # publisher have been deployed"), and an installer that never writes the key
  # leaves every appliance in the fleet on the slow path silently -- which is
  # exactly what happened: the subscriber shipped, the broker rule shipped,
  # and no installed gateway ever ran it.
  #
  # poll_interval is 30s rather than 60s, and it is NOT redundant with the
  # line above. The notification is a hint delivered at QoS 0 over a broker
  # connection that can be down, so the poll is the only thing that is
  # guaranteed to converge, and it is what a gateway falls back to when the
  # nudge does not arrive. 30s is the floor config.MinManagedPollInterval
  # permits; halving the worst case costs one signed GET per gateway per
  # thirty seconds, which is the cheapest request this product makes.
  managed_config:
    enabled: true
    base_url: "${PN_CLOUD_URL}"
    poll_interval: 30s
    change_notifications: true

  # Tells Cloud which sources exist, so they can be bound to assets. A gateway
  # without this has nothing to bind and its readings have nowhere to land.
  source_declaration:
    enabled: true
    base_url: "${PN_CLOUD_URL}"
    attempt_timeout: 30s

  # The per-metric publish decision. The gateway holds a reading rather than
  # inventing a destination for it, and this is how it is told where one goes.
  publish_authority:
    enabled: true
    base_url: "${PN_CLOUD_URL}"
    attempt_timeout: 30s

  # Software version, uptime, what it is collecting, whether it can take a
  # remote update. Off here means the fleet view is blank and nobody can tell a
  # quiet gateway from a dead one.
  health:
    enabled: true
    base_url: "${PN_CLOUD_URL}"
    attempt_timeout: 30s

  # The gateway's own administrator account, created at enrolment with a
  # password it generates and reports to Pointnode exactly once.
  #
  # On for new installs because the local interface now answers on the plant
  # network at https://pnedge.local, so every gateway needs an account -- and
  # the alternative to this is a commissioning engineer inventing one per
  # device, which in practice means the same password across a fleet, written
  # on the enclosure.
  #
  # Written only into a device.yaml this installer is creating from scratch:
  # the block above returns early when one already exists. An appliance
  # somebody commissioned by hand keeps its own account either way -- the
  # gateway's first look at a device that already has one records that it is
  # somebody else's and never touches it -- but not editing their file is the
  # more honest way to say so.
  #
  # Pointnode shows the password once and then keeps only the record that it
  # was shown. Nothing is ever sent back to the gateway, so a password nobody
  # wrote down is replaced by creating a new one, not by recovering it.
  local_admin:
    enabled: true
    base_url: "${PN_CLOUD_URL}"
    attempt_timeout: 30s

# Readings survive a reboot and a broker outage. Delivery is a SECOND gate on
# purpose -- mirroring to the durable outbox and sending it over the network
# are different permissions -- but a managed gateway needs both, and shipping
# with only the first produces a device that buffers perfectly and delivers
# nothing.
queue:
  durable: true

# Signed automatic updates: on by default. The gateway checks Pointnode Cloud
# for an update offered to it, installs only a release signed under the
# release root built into this software, proves it healthy, and rolls back on
# its own if it is not. Until a signed release exists, or while the rollback
# readiness check has not passed, this does nothing at all. Set to false to
# opt this gateway out.
update:
  automatic:
    enabled: true

$(history_config_block)

$(live_config_block)

# The local engineering interface, reachable from a laptop on any network the
# gateway is attached to -- the way a Cockpit or a WBM is. Still behind the
# same username and password: widening the bind does not widen the
# authentication.
#
# That last sentence is only true once an administrator account EXISTS. Before
# one does there is no password for anything to be behind, so the gateway does
# not serve its first-run screen on this binding at all: until it is claimed,
# the setup surface answers only a caller at the device itself, the bounded
# commissioning listener, or an operator who has armed a window with
# 'pointnode-edge -commission open'. See internal/api/first_run_guard.go. The
# cloud.local_admin block above is what ordinarily ends that state -- in
# process, the moment the gateway is claimed -- which is why a wide bind here
# is safe.
#
# 0.0.0.0 rather than one interface address because a wifi address comes from
# DHCP and pinning it would break on the next lease. Which networks can reach
# it is therefore a firewall question, handled below, and can be narrowed to
# the engineering LAN without touching this file.
#
# TLS is self_signed because the agent REFUSES to serve plaintext anywhere but
# loopback unless explicitly told otherwise, and that interlock is right.
http:
  bind_address: "0.0.0.0"
  port: ${PN_UI_PORT}
  redirect_port: ${PN_UI_REDIRECT_PORT}
  tls:
    mode: self_signed
    extra_hosts:
      - "${PN_MDNS_NAME}.local"
      - "${PN_MDNS_NAME}"
${PN_REMOTE_TUNNEL_BLOCK}
YAML
  chown root:"${PN_USER}" "${PN_CONFIG_DIR}/device.yaml"
  chmod 0640 "${PN_CONFIG_DIR}/device.yaml"
}

# The remote-service sidecar reads its interface roles from a strict JSON
# credential at /etc/pointnode-edge-remote/tunnel-interfaces.json -- the fixed
# path internal/tunnelhelper (RolesPath) and cmd/pointnode-edge-remote/tunnel.go
# both parse -- and NOT from device.yaml. write_config puts the roles in
# device.yaml for the acquisition service; without the line below the sidecar's
# own credential file never exists, so a commissioned gateway gets no tunnel arm
# and someone hand-writes the file in a locked cabinet. That is the same gap
# install_unit closed for the units, one file further on: provision the whole
# remote-access surface an installer safely can, so commissioning adds only the
# two things that must stay human -- the site's consent and the trust import.
#
# CONFIG-ONLY, exactly like the device.yaml block: it declares which NIC
# forwards (plant) and which must never (internet-facing) and enables nothing.
# The sidecar runs only once somebody at the site has given consent.
provision_tunnel_interface_map() {
  detect_tunnel_interfaces

  # The tunnel helper runs with ProtectKernelModules=yes, so it cannot load
  # the wireguard module itself; if nothing loaded it at boot, every bring-up
  # fails at "ip link add" (crane 9401, 2026-10-11, after a reboot). Load it at
  # every boot. Harmless where the module is built in or already loaded.
  if [ -d /etc/modules-load.d ] && [ ! -f /etc/modules-load.d/pointnode-wireguard.conf ]; then
    printf 'wireguard\n' > /etc/modules-load.d/pointnode-wireguard.conf
    chmod 0644 /etc/modules-load.d/pointnode-wireguard.conf
    modprobe wireguard >/dev/null 2>&1 || true
  fi

  # Both-or-neither and distinct -- the rule write_config and
  # config.validateRemoteTunnel already enforce. Neither known is not a failure:
  # an appliance nobody has named interfaces for gets no map and asks to be
  # told, because naming the forwarding side wrong is a safety fault.
  [ -n "${PN_PLANT_INTERFACE}" ] && [ -n "${PN_WAN_INTERFACE}" ] || return 0
  [ "${PN_PLANT_INTERFACE}" != "${PN_WAN_INTERFACE}" ] || return 0

  map="${PN_REMOTE_CONFIG_DIR}/tunnel-interfaces.json"
  if [ -f "$map" ]; then
    info "Keeping the existing ${map}"
    return
  fi

  # Byte-for-byte to the credential's contract: exactly the three members
  # schema, plant_interface, wan_interface, and schema pne-remote-tunnel-
  # interfaces/1. Both readers refuse an unknown or a missing member, so a
  # fourth field or a renamed one is a refusal, not a tolerated extra.
  printf '{"schema":"pne-remote-tunnel-interfaces/1","plant_interface":"%s","wan_interface":"%s"}\n' \
    "${PN_PLANT_INTERFACE}" "${PN_WAN_INTERFACE}" > "$map"
  chown root:root "$map"
  chmod 0600 "$map"
  info "Provisioned the remote-support network roles ${map} (plant=${PN_PLANT_INTERFACE}, wan=${PN_WAN_INTERFACE}). This does not turn remote support on."
}

# The management interface serves 443, and 80 to redirect to it. Binding below
# 1024 as a non-root service needs exactly one capability; the unit keeps
# NoNewPrivileges and an otherwise empty bounding set, so this grants the
# ability to bind a low port and nothing else.
allow_privileged_ports() {
  if [ "${PN_UI_PORT}" -ge 1024 ] && [ "${PN_UI_REDIRECT_PORT}" -ge 1024 ]; then
    return
  fi
  dir="/etc/systemd/system/${PN_SERVICE}.service.d"
  mkdir -p "$dir"
  printf '%s
'     '[Service]'     'AmbientCapabilities=CAP_NET_BIND_SERVICE'     'CapabilityBoundingSet=CAP_NET_BIND_SERVICE'     > "${dir}/10-bind-privileged-ports.conf"
  info "Granted CAP_NET_BIND_SERVICE so the interface can serve ${PN_UI_PORT}"
}

# A name an engineer can type. Avahi answers for it on every address the
# gateway holds, so it follows DHCP rather than pinning an address that changes
# on the next lease. Several gateways on one network stay distinguishable:
# avahi appends -2, -3 on collision.
publish_local_name() {
  conf=/etc/avahi/avahi-daemon.conf
  [ -f "$conf" ] || return 0
  # The backup is the pre-Pointnode file. Never replace it with the already
  # managed form on an upgrade, even if an interrupted uninstall removed it.
  if [ ! -f "${conf}.bak-pointnode" ] && ! grep -q '^# pointnode-edge-managed$' "$conf"; then
    cp -a "$conf" "${conf}.bak-pointnode"
  fi
  if grep -qE '^host-name=' "$conf"; then
    sed -i "s/^host-name=.*/host-name=${PN_MDNS_NAME}/" "$conf"
  else
    # GNU sed rejects a newline inside the s/// argument ("unterminated `s'
    # command"), and it exits 0 while doing it -- so under `set -eu` the
    # installer carried on and the gateway was never published as
    # ${PN_MDNS_NAME}.local. Only a FRESH install reaches this branch: an
    # appliance whose avahi config already has a host-name line takes the
    # rewrite above, which is why every upgrade looked fine.
    #
    # `a` appends after the [server] header with no newline in the script
    # argument. It is not idempotent on its own, but the grep guard above
    # means the second install takes the rewrite branch instead.
    sed -i "/^\[server\]$/a host-name=${PN_MDNS_NAME}" "$conf"
  fi
  grep -q '^# pointnode-edge-managed$' "$conf" \
    || sed -i '1i# pointnode-edge-managed' "$conf"
  systemctl restart avahi-daemon >/dev/null 2>&1 || true
  info "Publishing this gateway as ${PN_MDNS_NAME}.local"
}

# Some appliance images ship a vendor web server on 80 and 443. On a Revolution
# Pi that is apache, and it serves the vendor's own interface on 41443 as well
# -- so it is moved OFF 80 and 443 rather than disabled, which would take the
# vendor UI away with it. That distinction was learned the hard way.
free_the_web_ports() {
  [ "${PN_UI_PORT}" = "443" ] || return 0
  command -v apache2ctl >/dev/null 2>&1 || return 0
  ports=/etc/apache2/ports.conf
  [ -f "$ports" ] || return 0

  if ! ss -tlnp 2>/dev/null | grep -qE ":(80|443)\s.*apache2"; then
    return 0
  fi
  cp -a "$ports" "${ports}.bak-pointnode"
  sed -i 's/^Listen 80$/#Listen 80  # taken by pointnode-edge/' "$ports"
  sed -i 's/^\(\s*\)Listen 443$/\1#Listen 443  # taken by pointnode-edge/' "$ports"
  # Any vhost still bound to a port apache no longer listens on would refuse to
  # start, taking the vendor interface down with it.
  if [ -L /etc/apache2/sites-enabled/000-default.conf ]; then
    a2dissite 000-default >/dev/null 2>&1 || true
  fi
  if apache2ctl configtest >/dev/null 2>&1; then
    systemctl reload-or-restart apache2 >/dev/null 2>&1 || true
    info "Moved the vendor web server off 80 and 443; its own interface is unchanged"
  else
    # Put it back rather than leave a web server that will not start.
    mv "${ports}.bak-pointnode" "$ports"
    a2ensite 000-default >/dev/null 2>&1 || true
    systemctl reload-or-restart apache2 >/dev/null 2>&1 || true
    warn "Could not free ports 80 and 443; the local interface may not bind"
  fi
}

# The gateway cannot start if something else already holds its interface port,
# and systemd latches `start request repeated too quickly` after a few
# restarts -- leaving an appliance that enrolled, created a local administrator
# account, and then reported nothing at all. That is what a clean install on a
# Revolution Pi did on 2026-09-17.
#
# free_the_web_ports above moves apache aside, but it only knows apache: it
# returns early when apache2ctl is absent, and it rolls back -- correctly --
# when apache's own configtest fails with the Listen lines commented out.
# Either way the port can still be held here, and the old behaviour was to warn
# and then enable the unit regardless.
#
# Refuse instead, before anything is started, naming both the holder and the
# override. Our own listener is excluded, so an upgrade -- where pointnode-edge
# legitimately holds the port -- is never refused.
refuse_if_ui_port_held() {
  command -v ss >/dev/null 2>&1 || return 0
  holder="$(ss -tlnp 2>/dev/null | grep -E ":${PN_UI_PORT}\s" | grep -v 'pointnode-edge' | head -n 1)"
  [ -n "$holder" ] || return 0
  name="$(printf '%s' "$holder" | sed -n 's/.*users:(("\([^"]*\)".*/\1/p')"
  [ -n "$name" ] || name="another process"
  die_install "port ${PN_UI_PORT} is already served by ${name}, so ${PN_SERVICE} would not start.
Re-run with high ports, which need no vendor change:
  curl -fsSL ${PN_BASE_URL}/install.sh | sudo PN_UI_PORT=8443 PN_UI_REDIRECT_PORT=8080 sh
On an appliance that is already installed, edit http.port and http.redirect_port in ${PN_CONFIG_DIR}/device.yaml and restart ${PN_SERVICE}."
}

# A closed port is the same as a broken gateway to the engineer who drove out
# to it. RevPi images run firewalld with every interface in the public zone and
# nothing but ssh open, so without this the interface listens and nobody can
# reach it.
open_local_firewall() {
  command -v firewall-cmd >/dev/null 2>&1 || return 0
  systemctl is-active --quiet firewalld 2>/dev/null || return 0
  marker="${PN_CONFIG_DIR}/installer-firewall-ports"
  changed=0
  for port in "${PN_UI_PORT}" "${PN_UI_REDIRECT_PORT}"; do
    if firewall-cmd --permanent --zone=public --query-port="${port}/tcp" >/dev/null 2>&1; then
      continue
    fi
    if firewall-cmd --permanent --zone=public --add-port="${port}/tcp" >/dev/null 2>&1; then
      touch "$marker"
      chmod 0600 "$marker"
      grep -qx "$port" "$marker" 2>/dev/null || printf '%s\n' "$port" >> "$marker"
      changed=1
    fi
  done
  if [ "$changed" = "1" ]; then
    firewall-cmd --reload >/dev/null 2>&1 || true
    info "Opened ${PN_UI_PORT}/tcp and ${PN_UI_REDIRECT_PORT}/tcp on the local firewall"
  else
    info "The local UI ports were already allowed by the host firewall"
  fi
}

activate_installed_services() {
  # try-restart changes an already-running install but is a no-op on a fresh
  # one. The enable --now below remains the first-install path.
  systemctl try-restart "${PN_SERVICE}.service" >/dev/null 2>&1 \
    || warn "the running gateway could not be restarted onto the new binary"

  if systemctl is-active --quiet pointnode-edge-tunnel.service 2>/dev/null; then
    PN_REMOTE_RESTART_PENDING=1
    warn "A remote tunnel is live. Remote helpers were not restarted; the new binaries will take effect when the session ends."
    return 0
  fi
  systemctl try-restart pointnode-edge-remote.service pointnode-edge-tunnel.service \
    >/dev/null 2>&1 || warn "an active remote helper could not be restarted"
}

wait_for_service() {
  expected_version="${1:-}"
  start_timeout="$PN_SERVICE_START_TIMEOUT"
  case "$start_timeout" in
    ""|*[!0-9]*) start_timeout=90 ;;
  esac
  [ "$start_timeout" -ge 10 ] || start_timeout=10
  [ "$start_timeout" -le 900 ] || start_timeout=900
  i=0
  while [ "$i" -lt "$start_timeout" ]; do
    if systemctl is-active --quiet "${PN_SERVICE}"; then
      main_pid="$(systemctl show --property MainPID --value "${PN_SERVICE}" 2>/dev/null || true)"
      if [ -n "$expected_version" ] && [ "${main_pid:-0}" -gt 0 ] 2>/dev/null; then
        running_version="$("/proc/${main_pid}/exe" -version 2>/dev/null | sed -n '1p')"
        if [ "$running_version" = "$expected_version" ]; then
          return 0
        fi
        # Under binfmt emulation (and some hardened kernels) /proc/PID/exe is the
        # interpreter, not the gateway, so it never reports our version. Accept
        # instead when the service's main process started after the new binary
        # was installed and that installed binary reports the expected version.
        if [ -n "${PN_BINARY_SWAPPED_US:-}" ]; then
          started_us="$(systemctl show --property ExecMainStartTimestampMonotonic --value "${PN_SERVICE}" 2>/dev/null || true)"
          installed_version="$("${PN_PREFIX}/pointnode-edge" -version 2>/dev/null | sed -n '1p')"
          case "${started_us:-}" in
            ''|*[!0-9]*) : ;;
            *)
              if [ "$started_us" -gt "$PN_BINARY_SWAPPED_US" ] && [ "$installed_version" = "$expected_version" ]; then
                return 0
              fi
              ;;
          esac
        fi
      elif [ -z "$expected_version" ]; then
        return 0
      fi
    fi
    i=$((i + 1))
    sleep 1
  done
  warn "the service did not become active within ${start_timeout}s"
  [ -z "$expected_version" ] || warn "the running service did not report the newly installed version"
  warn "check: journalctl -u ${PN_SERVICE} -n 50"
  return 1
}

# ── enrol ───────────────────────────────────────────────────────────────
enrol() {
  code="$1"

  case "$code" in
    pne_setup_*) : ;;
    *) die "that does not look like a setup code (expected pne_setup_...)" ;;
  esac

  if [ "${PN_ALREADY_ENROLLED}" = "1" ]; then
    info "Already enrolled; ignoring the setup code."
    return 0
  fi

  # stdin, never argv. argv is readable by any local user through `ps` for as
  # long as the process runs, and a setup code is a bearer credential.
  if printf '%s' "$code" | "${PN_PREFIX}/pointnode-edge" -enrol \
       -config "${PN_CONFIG_DIR}/device.yaml"; then
    # The enrolment ran as root, because this whole script does. The SERVICE
    # runs as ${PN_USER} (User= in the unit), so every file the enrolment just
    # wrote -- the certificate, the CA, the private key, the identity database
    # -- was owned by root and could not be opened by the process that needs
    # it.
    #
    # The gateway then started, reported "the stored device certificate could
    # not be loaded", and retried the cloud connection for ever while Cloud
    # showed it as successfully enrolled. Both halves telling the truth, and
    # the pair useless.
    #
    # PNR2 rollback authority is held separately in the root-owned
    # PN_PNR2_STATE_DIR. This tree contains only service-owned identity,
    # configuration and queued data, so repairing it recursively is safe.
    if ! chown -R "${PN_USER}:${PN_USER}" "${PN_STATE_DIR}" 2>/dev/null; then
      warn "could not give ${PN_STATE_DIR} to ${PN_USER};"
      warn "the service will not be able to read its own credential"
    fi
	if ! systemctl restart "${PN_SERVICE}.service" >/dev/null 2>&1; then
	  PN_SERVICE_READY=0
	  warn "the gateway enrolled, but its service could not restart"
	elif ! wait_for_service "$PN_EXPECTED_VERSION"; then
	  PN_SERVICE_READY=0
	  warn "the gateway enrolled, but the restarted service is not ready"
	fi
    info "Enrolled."
    info ""
    info "This gateway is creating its own administrator account and sending"
    info "the password to Pointnode, which shows it ONCE. Open the gateway in"
    info "Pointnode to read it -- it is not stored on this device and cannot"
    info "be recovered from here."
  else
    warn "enrolment did not complete. The gateway is installed and running."
    warn "Retry with:"
    warn "  printf '%s' '<setup code>' | pointnode-edge -enrol"
    return 1
  fi
}

report() {
  info ""
  info "--------------------------------"
  info "  binary   ${PN_PREFIX}/pointnode-edge"
  info "  config   ${PN_CONFIG_DIR}/device.yaml"
  info "  state    ${PN_STATE_DIR}"
  info "  service  $(systemctl is-active ${PN_SERVICE} 2>/dev/null || echo unknown)"
  info "  enrolment ${PN_ENROLMENT_STATUS:-unknown}"
  info "  local UI https://${PN_MDNS_NAME}.local"
  if [ "$PN_REMOTE_RESTART_PENDING" = "1" ]; then
    info "  remote   restart pending until the live tunnel ends"
  fi
  info ""
  info "  logs     journalctl -u ${PN_SERVICE} -f"
  info "  status   pointnode-edge -config ${PN_CONFIG_DIR}/device.yaml check-config"
  # Printed only when there is something to roll back to, so a first install
  # does not offer a recovery step that would not work.
  if [ -f "${PN_PREFIX}/pointnode-edge.prev" ]; then
    info ""
    info "  The binary this replaced is kept at ${PN_PREFIX}/pointnode-edge.prev."
    info "  If this release misbehaves, go back with:"
    info ""
    info "    sudo mv ${PN_PREFIX}/pointnode-edge.prev ${PN_PREFIX}/pointnode-edge"
    info "    sudo systemctl restart ${PN_SERVICE}"
  fi
  info ""
  report_remote_access
}

# An operator should finish this command knowing whether remote access will
# work. The three answers are ready, not ready and unknown, and unknown is
# printed as unknown -- a closing report that rounds "I could not test this" up
# to "ready" is worse than no line at all, because it is believed.
report_remote_access() {
  info "  remote support"

  # What the SITE has decided, read from the root-owned document the local
  # authority writes. The same canonical prefix the consent reconciler keys on;
  # anything else, including no document at all, is "not given".
  remote_consent="not given"
  case "$(head -c 67 /var/lib/pointnode-edge-local-authority/remote-authority.json 2>/dev/null)" in
    '{"schema":"pne-remote-local-authority/1","site_allows_remote":true,') remote_consent="given" ;;
  esac

  if [ "$PN_REMOTE_AUTHORITY_READY" = "1" ] || \
     systemctl is-active --quiet pointnode-edge-remote-authority.socket 2>/dev/null; then
    info "    consent service   running"
  else
    info "    consent service   NOT RUNNING -- remote support cannot be turned on."
    info "                      Re-running this installer starts it."
  fi
  if [ -f "${PN_REMOTE_CONFIG_DIR}/server-trust.pem" ]; then
    info "    Pointnode trust   provisioned"
  else
    info "    Pointnode trust   not yet -- provisioned automatically once this"
    info "                      gateway is enrolled and this installer is run again"
  fi
  if [ "$remote_consent" = "given" ]; then
    if systemctl is-active --quiet pointnode-edge-remote.service 2>/dev/null; then
      info "    site consent      given; the remote-support service is running"
    else
      info "    site consent      given, but the remote-support service is not"
      info "                      running yet. The local interface names what is"
      info "                      missing (Security -> Remote support)."
    fi
  else
    info "    site consent      not given. Remote support is OFF. Somebody at the"
    info "                      site turns it on at https://${PN_MDNS_NAME}.local"
    info "                      (Commissioning or Security -> Remote support)."
    info "                      Pointnode cannot turn it on for you."
  fi

  if [ -z "$PN_TUNNEL_MISSING" ] && [ "$PN_WG_KERNEL" = "yes" ]; then
    info "    network tunnels   supported: wg, ip, nft and sysctl are where the"
    info "                      tunnel helper looks for them, and this kernel"
    info "                      accepted a WireGuard interface when asked."
  else
    info "    network tunnels   NOT SUPPORTED on this host yet. Telemetry, and"
    info "                      remote browser access, are unaffected; a full"
    info "                      network tunnel needs the following resolved."
  fi

  if [ -n "$PN_TUNNEL_MISSING" ]; then
    info ""
    info "    missing:${PN_TUNNEL_MISSING}"
    printf '%s' "$PN_TUNNEL_NOTES" | while IFS= read -r note_line; do
      if [ -n "$note_line" ]; then
        info "    ${note_line}"
      fi
    done
    if [ -z "$PN_TUNNEL_PACKAGES" ]; then
      # Everything missing was found somewhere else on this machine, so there
      # is nothing to download and no command to offer. The disagreement is
      # between this appliance's layout and the helper's path list, which is
      # ours to fix and not the operator's.
      info ""
      info "    Nothing needs installing: every binary above is present under"
      info "    another path. Report these lines to Pointnode support."
    else
      fix="$(remediation_command)"
      if [ -n "$fix" ]; then
        info ""
        info "    This installer does not install distribution packages -- see"
        info "    the note above check_remote_access for why. Run:"
        info ""
        info "      ${fix}"
        info ""
        info "    then re-run this installer. Re-running is safe: it leaves an"
        info "    enrolled gateway's identity, configuration and data alone."
      else
        # No honest command to offer. Naming the requirement beats inventing a
        # package name for a platform this has never been tried on.
        info ""
        info "    Install the packages providing wg(8), ip(8), nft(8) and"
        info "    sysctl(8) with this platform's package manager, then re-run"
        info "    this installer."
      fi
    fi
  fi

  case "$PN_WG_KERNEL" in
    yes) : ;;
    no)
      info ""
      info "    kernel: no WireGuard support."
      info "    ${PN_WG_KERNEL_DETAIL}"
      ;;
    *)
      info ""
      info "    kernel: unknown -- this was not proven either way."
      info "    ${PN_WG_KERNEL_DETAIL}"
      ;;
  esac

  info ""
}

# The last line, and the only line that runs anything. See the note at the top
# about truncated downloads.
main "$@"
