#!/bin/bash
# Unbound macOS runtime bootstrap — delivered as a Jamf Script payload
# (WEB-4791). The script itself never travels over the network; only the
# hash-pinned pkg does (TLS + sha256 + Developer ID Team ID must all agree).
# macOS built-ins only: curl, system_profiler, plutil, shasum, pkgutil,
# installer, launchctl. No jq — it does not ship with macOS.
#
# Rendered by release-macos-runtime.yml: VERSION / PKG_SHA256 / ARTIFACT_URL
# / TEAM_ID below are baked in per release.
# Discovery source in this release: main @ d82268446e4d2d74e2383a9244e58889f98ccd2e
#
# NOTE for admins: script parameters (including the MDM key) are visible in
# Jamf policy logs — true of the python onboarding today as well. Use a
# scoped MDM enrollment key, not a personal/admin API key.
VERSION="0.1.30"
PKG_SHA256="527a1293a2be5544349220e8a7215a2129ee30f300fcc466c46393e3ef3e7523"
ARTIFACT_URL="https://unbound-release-artifacts.s3.us-west-2.amazonaws.com/macos/0.1.30/unbound-runtime-0.1.30.pkg"
EXPECTED_TEAM_ID="ZMA55FTA8W"   # empty = skip signature assert (unsigned dev builds only)

PREFIX="/opt/unbound"
PKG_ID="ai.getunbound.runtime"
DAEMON_LABEL="ai.getunbound.discovery"
MIN_MACOS_MAJOR=13
REQUIRED_MB=300

set -euo pipefail

# macOS-only: the runtime is a Mach-O .pkg and this script uses sw_vers /
# pkgutil / installer / launchctl, none present on Linux. Refuse on any other
# OS BEFORE the EXIT trap is installed, so a non-Mac run fails with one clear
# line instead of a cascade of "sw_vers: command not found" and a malformed
# install-report POST (empty OS field -> backend 400).
if [[ "$(uname -s)" != "Darwin" ]]; then
  echo "Unbound runtime installer is macOS-only (detected $(uname -s)). No changes made." >&2
  exit 1
fi

API_KEY="" DISCOVERY_KEY="" BACKEND_URL="https://backend.getunbound.ai"
GATEWAY_URL="https://api.getunbound.ai" CLEAR=0 BACKFILL=0 SKIP_MANAGED=0
# Optional tenant override with no baked default: empty = the flag is not
# passed to `setup` at all.
FRONTEND_URL=""
CURRENT_STEP="init"
tmpdir=""
# Set to 1 only on the install path right before its `exit 0`, so the EXIT
# trap can tell a real install completion apart from the other zero-exit
# paths (CLEAR teardown, early arg errors). Without this flag the trap would
# misreport `--clear` runs as successful installs.
install_succeeded=0

# Minimal JSON string escaping (backslash + double quote): hostnames are
# admin-settable via scutil and CAN contain either — an unescaped value
# would make the payload invalid JSON and the report would be silently
# dropped exactly when we need it.
# shellcheck disable=SC2329  # invoked from on_exit (EXIT trap)
json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/[[:cntrl:]]//g'; }

# Hardware serial — the key the install-report endpoint joins on (one row per
# device, keyed by org+serial_number). ioreg's IOPlatformSerialNumber is
# locale-stable; system_profiler's "Serial Number" label is localized and
# breaks on non-English macOS. Empty string if it can't be read (the trap
# report just gets dropped server-side rather than misattributing the row).
# shellcheck disable=SC2329  # invoked from on_exit (EXIT trap)
device_serial() {
  ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null \
    | awk -F'"' '/IOPlatformSerialNumber/{print $4; exit}'
}

# shellcheck disable=SC2329  # invoked indirectly via the EXIT trap below
on_exit() {
  local code=$1
  # Always reap the download dir: on failure, set -e skips the inline rm
  # and a leftover ~100MB pkg in /tmp can fail the NEXT run's free-space
  # preflight.
  [[ -n "$tmpdir" ]] && rm -rf "$tmpdir"
  if [[ $code -ne 0 ]]; then
    echo "UNBOUND_INSTALL_FAILED step=${CURRENT_STEP} code=${code}" >&2
    # Best-effort failure report: this is the bash-trap "failure report" shape
    # the endpoint validates (serial_number + step + exit_code, plus optional
    # installer_version/ts) — NOT a full component snapshot. Field names must
    # match the handler exactly or it 400s and is dropped silently, which is
    # the bug this trap is supposed to surface (WEB-4826). Auth is the gateway
    # API key in X-API-KEY, the same header every setup path uses.
    #
    # Fail-open: the install has already finished by the time on_exit runs, so
    # a failed POST must never change the script's exit status — but it is no
    # longer silent: a non-2xx (or no-response) prints a diagnostic to stderr
    # so a regression can't hide in empty fleet telemetry again.
    local report_serial report_body http_code
    report_serial="$(json_escape "$(device_serial)")" || report_serial=""
    report_body="$(printf '{"serial_number":"%s","step":"%s","exit_code":%d,"installer_version":"%s","hostname":"%s","os_version":"%s","ts":%d}' \
      "$report_serial" "$(json_escape "$CURRENT_STEP")" "$code" \
      "$(json_escape "$VERSION")" "$(json_escape "$(hostname)")" \
      "$(json_escape "$(sw_vers -productVersion)")" "$(date +%s)")"
    # -f dropped on purpose: we WANT the body/status on 4xx/5xx, not a curl
    # exit. -o /dev/null discards the response body; -w prints just the code.
    http_code="$(curl -sS -m 10 -o /dev/null -w '%{http_code}' \
      -X POST "${BACKEND_URL}/api/v1/automations/mdm/install-report/" \
      -H 'Content-Type: application/json' \
      -H "X-API-KEY: ${API_KEY}" \
      -d "$report_body" 2>/dev/null)" || http_code="000"
    case "$http_code" in
      2??) ;;  # reported
      *) echo "::install-report POST failed: HTTP ${http_code} (step=${CURRENT_STEP})" >&2 ;;
    esac
  elif [[ $install_succeeded -eq 1 ]]; then
    # Best-effort SUCCESS report (WEB-4826): without this, fleet install
    # coverage was failure-only and a healthy install reported nothing —
    # blank coverage was indistinguishable from total rollout failure. Same
    # endpoint/auth/payload shape as the failure branch above; the backend
    # distinguishes success purely by exit_code:0 (no new schema), so step is
    # the final "setup" step and exit_code is hard-coded 0.
    #
    # Fail-open is sacred here: the install has already fully succeeded by the
    # time the trap runs, so this POST must NEVER change the exit status, block
    # the install, or turn a telemetry hiccup into a reported failure. Every
    # failure mode (curl error, timeout, 4xx/5xx, 404, no response) is swallowed
    # to a stderr diagnostic only — identical tolerance to the failure branch.
    local report_serial report_body http_code
    report_serial="$(json_escape "$(device_serial)")" || report_serial=""
    report_body="$(printf '{"serial_number":"%s","step":"%s","exit_code":0,"installer_version":"%s","hostname":"%s","os_version":"%s","ts":%d}' \
      "$report_serial" "$(json_escape "$CURRENT_STEP")" \
      "$(json_escape "$VERSION")" "$(json_escape "$(hostname)")" \
      "$(json_escape "$(sw_vers -productVersion)")" "$(date +%s)")"
    http_code="$(curl -sS -m 10 -o /dev/null -w '%{http_code}' \
      -X POST "${BACKEND_URL}/api/v1/automations/mdm/install-report/" \
      -H 'Content-Type: application/json' \
      -H "X-API-KEY: ${API_KEY}" \
      -d "$report_body" 2>/dev/null)" || http_code="000"
    case "$http_code" in
      2??) ;;  # reported
      *) echo "::install-report POST failed: HTTP ${http_code} (step=${CURRENT_STEP})" >&2 ;;
    esac
  fi
}
trap 'on_exit $?' EXIT

# macOS has no `timeout`; bash-only watchdog so a wedged binary cannot hang
# the Jamf policy forever. The watchdog's stdio is detached (>/dev/null) so
# an orphaned sleep can never hold the pipe Jamf/installer reads open past
# script exit (which reads as a multi-minute hang), and pkill -P reaps the
# sleep on the fast path.
run_with_timeout() {
  local secs="$1"; shift
  "$@" & local pid=$!
  ( sleep "$secs" && kill -9 "$pid" 2>/dev/null ) >/dev/null 2>&1 & local watchdog=$!
  local rc=0
  wait "$pid" 2>/dev/null || rc=$?
  pkill -P "$watchdog" 2>/dev/null || true
  kill "$watchdog" 2>/dev/null || true; wait "$watchdog" 2>/dev/null || true
  return "$rc"
}

# Positional token matchers for the Jamf $4-$11 mapping: each slot accepts
# generic booleans plus ONLY its own named token, so a value landing in the
# wrong slot (e.g. "backfill" in the clear slot) can never trigger teardown.
is_clear_token()    { case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in 1|true|yes|clear|--clear) return 0;; *) return 1;; esac; }
is_backfill_token() { case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in 1|true|yes|backfill|--backfill) return 0;; *) return 1;; esac; }
is_skip_managed_token() { case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in 1|true|yes|skip-managed-settings|--skip-managed-settings) return 0;; *) return 1;; esac; }

# One check for --frontend-url and Jamf $11. Whitespace-only = not given. A
# leading "-" is a flag or token that landed in the wrong slot, never a URL:
# refuse it rather than record it in every user's config.
set_frontend_url() {
  local compact="${1//[[:space:]]/}"
  [[ -n "$compact" ]] || return 0
  [[ "$compact" != -* ]] || { echo "--frontend-url requires a value" >&2; exit 2; }
  FRONTEND_URL="$1"
}

# --- Arguments: --flags for direct runs, $4-$11 positional from Jamf ---------
# $4 api key, $5 discovery key (deprecated, ignored), $6 backend url,
# $7 gateway url, $8 clear token, $9 backfill token, $10 skip-managed token,
# $11 frontend url. $6, $7 and $11 are optional tenant overrides.
CURRENT_STEP="parse_args"
if [[ $# -gt 0 && "${1:-}" != --* ]]; then
  # Jamf convention: $1 mount point, $2 computer name, $3 username, params at $4+.
  API_KEY="${4:-}"; DISCOVERY_KEY="${5:-}"
  [[ -n "${6:-}" ]] && BACKEND_URL="$6"
  [[ -n "${7:-}" ]] && GATEWAY_URL="$7"
  is_clear_token "${8:-}" && CLEAR=1
  is_backfill_token "${9:-}" && BACKFILL=1
  is_skip_managed_token "${10:-}" && SKIP_MANAGED=1
  set_frontend_url "${11:-}"
else
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --api-key)       API_KEY="$2"; shift 2 ;;
      # Deprecated and ignored. Consumed with or without a value so a valueless
      # flag neither aborts under `set -u` nor swallows the flag after it.
      --discovery-key) if [[ "${2:-}" == --* || $# -lt 2 ]]; then shift; else DISCOVERY_KEY="$2"; shift 2; fi ;;
      --backend-url)   BACKEND_URL="$2"; shift 2 ;;
      --gateway-url)   GATEWAY_URL="$2"; shift 2 ;;
      --frontend-url)
        [[ $# -ge 2 ]] || { echo "--frontend-url requires a value" >&2; exit 2; }
        set_frontend_url "$2"; shift 2 ;;
      --clear)         CLEAR=1; shift ;;
      --backfill)      BACKFILL=1; shift ;;
      --skip-managed-settings) SKIP_MANAGED=1; shift ;;
      *) echo "Unknown argument: $1" >&2; exit 2 ;;
    esac
  done
fi

# --- Clear: binary first, bash-only fallback that needs no healthy binary ---
if [[ $CLEAR -eq 1 ]]; then
  CURRENT_STEP="clear"
  [[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
  bin="$PREFIX/current/unbound-hook/unbound-hook"
  if [[ -x "$bin" ]] && run_with_timeout 300 "$bin" clear; then
    echo "binary teardown complete"
  else
    echo "binary unavailable or failed; using bash fallback teardown"
  fi
  # Always sweep system-level pieces, even after a successful binary clear.
  launchctl bootout "system/$DAEMON_LABEL" 2>/dev/null || true
  rm -f "/Library/LaunchDaemons/${DAEMON_LABEL}.plist" /etc/newsyslog.d/ai.getunbound.conf
  rm -rf "$PREFIX"
  pkgutil --forget "$PKG_ID" >/dev/null 2>&1 || true
  echo "UNBOUND_CLEAR_OK"
  exit 0
fi

# --- Preflight ---------------------------------------------------------------
CURRENT_STEP="preflight"
[[ $EUID -eq 0 ]] || { echo "must run as root (Jamf runs scripts as root)" >&2; exit 1; }
[[ -n "$API_KEY" ]] || { echo "--api-key is required" >&2; exit 2; }
# Accepted and ignored so Jamf policies that still fill the key slot keep working:
# the scan authenticates as the device owner, resolved from the hardware serial.
[[ -n "$DISCOVERY_KEY" ]] && echo "--discovery-key is deprecated and ignored" >&2
os_major="$(sw_vers -productVersion | cut -d. -f1)"
[[ "$os_major" -ge $MIN_MACOS_MAJOR ]] || { echo "macOS $os_major < required $MIN_MACOS_MAJOR" >&2; exit 1; }
avail_mb="$(df -Pm / | awk 'NR==2{print $4}')"
[[ "$avail_mb" -ge $REQUIRED_MB ]] || { echo "need ${REQUIRED_MB}MB free, have ${avail_mb}MB" >&2; exit 1; }
curl -fsI -m 15 "$ARTIFACT_URL" >/dev/null || { echo "artifact host unreachable: $ARTIFACT_URL" >&2; exit 1; }

# --- Idempotent re-run: same version already live -> skip straight to setup --
CURRENT_STEP="version_check"
installed="$(pkgutil --pkg-info "$PKG_ID" 2>/dev/null | awk '/^version:/{print $2}' || true)"
if [[ "$installed" == "$VERSION" \
      && "$(readlink "$PREFIX/current" 2>/dev/null)" == "$PREFIX/$VERSION" \
      && -x "$PREFIX/current/unbound-hook/unbound-hook" ]]; then
  echo "runtime $VERSION already installed; skipping download/install"
else
  CURRENT_STEP="download"
  tmpdir="$(mktemp -d /tmp/unbound-onboard.XXXXXX)"
  pkg="$tmpdir/unbound-runtime.pkg"
  # The preflight HEAD and this GET are two separate fetches; the baked
  # sha256 below is what makes that safe — swapped content fails shasum -c.
  curl -fSL --retry 3 --retry-delay 2 -m 600 -o "$pkg" "$ARTIFACT_URL"

  CURRENT_STEP="verify_sha256"
  printf '%s  %s\n' "$PKG_SHA256" "$pkg" | shasum -a 256 -c -

  CURRENT_STEP="verify_signature"
  if [[ -n "$EXPECTED_TEAM_ID" ]]; then
    sig="$(pkgutil --check-signature "$pkg")"
    # Anchor the Team ID to the Developer ID Installer LEAF line itself —
    # a bare substring match over the whole output could be satisfied by
    # other cert-subject text.
    leaf="$(printf '%s\n' "$sig" | grep 'Developer ID Installer' | head -1)"
    [[ -n "$leaf" ]] \
      || { echo "pkg is not signed with a Developer ID Installer cert" >&2; exit 1; }
    printf '%s\n' "$leaf" | grep -qF "($EXPECTED_TEAM_ID)" \
      || { echo "pkg Team ID mismatch (expected $EXPECTED_TEAM_ID)" >&2; exit 1; }
  else
    echo "WARNING: EXPECTED_TEAM_ID empty — skipping signature assert (unsigned build)"
  fi

  CURRENT_STEP="install_pkg"
  installer -pkg "$pkg" -target /
  rm -rf "$tmpdir"
fi

# --- Migration sweep: python-era leftovers ----------------------------------
# Per-user tool config migration is owned by `unbound-hook setup`; this only
# removes system-level python-era artifacts that would shadow the runtime.
CURRENT_STEP="migration_sweep"
launchctl bootout system/ai.getunbound.coding-discovery 2>/dev/null || true
rm -f /Library/LaunchDaemons/ai.getunbound.coding-discovery.plist
if [[ -f /usr/local/bin/unbound-hook && ! -L /usr/local/bin/unbound-hook ]]; then
  rm -f /usr/local/bin/unbound-hook   # legacy script shim, not our symlink
fi

# --- Hand off to the runtime ------------------------------------------------
# Plain invocation, NOT exec: exec would replace this shell and kill the
# EXIT trap, so a failing setup would never print UNBOUND_INSTALL_FAILED or
# POST the install report. set -e propagates a non-zero setup exit through
# the trap with step=setup.
CURRENT_STEP="setup"
backfill_flag=""
[[ $BACKFILL -eq 1 ]] && backfill_flag="--backfill"
skip_managed_flag=""
[[ $SKIP_MANAGED -eq 1 ]] && skip_managed_flag="--skip-managed-settings"
# Optional URL value, so an array (never word-split or globbed) rather than an
# unquoted string. The ${arr[@]+...} form is required: macOS ships bash 3.2,
# where expanding an EMPTY array under `set -u` is an "unbound variable" abort.
frontend_args=()
[[ -n "$FRONTEND_URL" ]] && frontend_args=(--frontend-url "$FRONTEND_URL")
"$PREFIX/current/unbound-hook/unbound-hook" setup \
  --api-key "$API_KEY" \
  --backend-url "$BACKEND_URL" \
  --gateway-url "$GATEWAY_URL" \
  ${frontend_args[@]+"${frontend_args[@]}"} \
  $backfill_flag $skip_managed_flag

# Reached only when every step above (incl. the idempotent re-run skip) and
# the runtime `setup` handoff succeeded. Mark success so the EXIT trap emits a
# success install-report; the trap is what actually POSTs (telemetry stays out
# of the main flow, and a success report can never block this exit).
install_succeeded=1
exit 0
