#!/bin/sh
# Tokenbase collector installer.  Usage:
#
#   curl -fsSL https://tokenbasehq.com/install.sh | sh
#
# Detects OS + arch, downloads the matching prebuilt binary from the public
# releases bucket, verifies it, and installs it to ~/.local/bin/tokenbase.
# POSIX sh, no bashisms.
#
# It installs per-user (no sudo) on purpose: the collector runs as a per-user
# service (launchd LaunchAgent / systemd --user) and updates itself in place, so
# the binary must be owned by the user that runs it. Override the directory with
# TOKENBASE_INSTALL_DIR for a system path (you'll then own keeping it updated, or
# run with TOKENBASE_UPDATE=0 to disable self-update).
#
# Headless servers (CI runners, build boxes): a per-user systemd unit stops at
# logout and won't start at boot without lingering, so after this installs the
# binary you can register a boot-persistent system service instead:
#   sudo tokenbase service install --system
# It writes a systemd system unit (or a macOS LaunchDaemon) that starts at boot
# and runs as the enrolling user (run `tokenbase service` for usage).
#
# Binaries are published by .github/workflows/release.yml on each `v*` tag to a
# public Cloudflare R2 bucket.  Override the host with TOKENBASE_INSTALL_BASE.
#
# Fleet teardown: removal is the mirror of install. Push `tokenbase uninstall`
# the same way you push this script (MDM such as Jamf/Intune, or Ansible/Chef);
# it deregisters the device server-side and removes the binary, service, and all
# local secrets. Add --remove-binary to delete the executable too.
#
# Idempotent / config-management friendly (Ansible, Chef, Puppet):
#   Safe to run repeatedly. The installer inspects the desired state first and
#   does ONLY the work that is missing. When the installed binary already matches
#   the latest published checksum, the device is enrolled, and the service is
#   running (subject to your TOKENBASE_NO_ENROLL / TOKENBASE_NO_SERVICE intent),
#   it makes no changes and prints a line beginning "Unchanged:". Otherwise it
#   performs only the missing steps (upgrade and/or enroll and/or service) and
#   prints a line beginning "Changed:". Re-running never re-downloads an
#   up-to-date binary and never re-runs `login` on an already-linked device, so
#   wrapping this in a config-management resource will not flap.
#
#   Dry run: pass --check (or set TOKENBASE_CHECK=1) to report what WOULD change
#   without downloading, enrolling, or touching the service. Exit codes for a
#   --check run:
#     0   already in the desired state (no change needed)
#     10  one or more changes are needed
#   Any other nonzero exit is a hard error. A normal (non --check) run exits 0 on
#   success whether or not it changed anything; grep stdout for "Unchanged:" vs
#   "Changed:" to key a config-management changed/unchanged signal off a real run.

set -eu

BASE="${TOKENBASE_INSTALL_BASE:-https://pub-915caef3f6e54d48b2f0ef6b62445d69.r2.dev/latest}"
# Refuse a non-https download host: the checksum/signature catch tampering, but
# we still won't pull the binary over a downgraded transport.
case "$BASE" in
  https://*) ;;
  *) printf 'Error: TOKENBASE_INSTALL_BASE must be https:// (got %s)\n' "$BASE" >&2; exit 1 ;;
esac
BIN_NAME="tokenbase"

# Ed25519 release public key (PEM; matches RELEASE_PUBLIC_KEY_B64 baked into the
# collector). Used to verify the signed checksum manifest when openssl is present.
RELEASE_PUBKEY_PEM='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAjsSkiyMr+Vd6oEYQV9K4b6jQJ1B430kzbcX87Emzu4Y=
-----END PUBLIC KEY-----'

err() {
  printf 'tokenbase install: %s\n' "$1" >&2
  exit 1
}

# --- dry-run / check mode ---------------------------------------------------
# --check (or TOKENBASE_CHECK=1) reports what would change and exits without
# making any changes. Used by config-management tools to read the desired-state
# signal without doing work.
check_only=0
case "${TOKENBASE_CHECK:-0}" in
  1) check_only=1 ;;
esac
for _arg in "$@"; do
  case "$_arg" in
    --check) check_only=1 ;;
    *) err "unknown argument: $_arg (only --check is supported)" ;;
  esac
done

# --- detect OS + arch -> published asset name -------------------------------
os="$(uname -s)"
arch="$(uname -m)"

case "$os" in
  Darwin)
    case "$arch" in
      arm64|aarch64) asset="tokenbase-macos-arm64" ;;
      x86_64|amd64)  asset="tokenbase-macos-x64" ;;
      *) err "unsupported macOS architecture: $arch" ;;
    esac
    ;;
  Linux)
    case "$arch" in
      x86_64|amd64) asset="tokenbase-linux-x64" ;;
      *) err "unsupported Linux architecture: $arch (only x86_64 is prebuilt)" ;;
    esac
    ;;
  *)
    err "unsupported OS: $os (macOS and Linux are supported)"
    ;;
esac

# --- pick a downloader ------------------------------------------------------
if command -v curl >/dev/null 2>&1; then
  download() { curl -fsSL "$1" -o "$2"; }
elif command -v wget >/dev/null 2>&1; then
  download() { wget -qO "$2" "$1"; }
else
  err "need curl or wget to download the binary"
fi

# --- sha256 tool (resolved up front: needed to checksum the EXISTING binary so
# we can tell "already up to date" without re-downloading the big binary, and to
# verify the download). Hard requirement: an unverifiable install of a telemetry
# collector is worse than no install, and every box that can run the collector
# has one of these tools -----------------------------------------------------
if command -v shasum >/dev/null 2>&1; then
  hash_cmd() { shasum -a 256 "$1"; }
elif command -v sha256sum >/dev/null 2>&1; then
  hash_cmd() { sha256sum "$1"; }
else
  err "no sha256 tool found — install shasum or sha256sum and re-run (refusing to install without checksum verification)"
fi

# --- install dir: per-user by default so the binary is self-update-writable --
# Resolved up front (no mkdir yet) so the idempotency gate can inspect $dest.
target_dir="${TOKENBASE_INSTALL_DIR:-$HOME/.local/bin}"
dest="$target_dir/$BIN_NAME"

tmp="$(mktemp 2>/dev/null || mktemp -t tokenbase)"
sums="$(mktemp 2>/dev/null || mktemp -t tokenbasesums)"
sig="$(mktemp 2>/dev/null || mktemp -t tokenbasesig)"
pub="$(mktemp 2>/dev/null || mktemp -t tokenbasepub)"
trap 'rm -f "$tmp" "$sums" "$sig" "$sig.bin" "$pub"' EXIT INT TERM

# --- desired-state intent ---------------------------------------------------
# What this run is meant to converge to, honouring the escape hatches. The
# service is only ever started for an enrolled device, so service intent implies
# enroll intent.
enroll_wanted=1
if [ "${TOKENBASE_NO_ENROLL:-0}" = 1 ]; then enroll_wanted=0; fi
service_wanted=1
if [ "$enroll_wanted" = 0 ]; then service_wanted=0; fi
if [ "${TOKENBASE_NO_SERVICE:-0}" = 1 ]; then service_wanted=0; fi

# --- back-compat-safe state probes ------------------------------------------
# These inspect the binary/state ALREADY on disk, so they must rely only on
# signals that exist in every shipped collector (filesystem layout + the
# platform service manager), never on a flag added to the binary later.

# device.json is written on enroll and is what the collector's own isEnrolled()
# checks, so its presence is a stable "already linked" signal across versions.
is_enrolled() {
  [ -f "$HOME/.tokenbase/device.json" ]
}

# Is a tokenbase service already managed on this host? Checks BOTH the system
# domain (a boot-persistent unit installed by `service install --system`, or what
# the binary auto-installs when this script runs as root) and the per-user domain.
# The system unit is invisible to the per-user probes, so it is checked first via
# its world-readable unit/plist path, which needs no privilege. Falls back to the
# unit/plist file when the manager CLI is unavailable.
is_service_running() {
  case "$os" in
    Darwin)
      # System LaunchDaemon lives under /Library and is invisible to a non-root
      # `launchctl list`; its plist is the stable, privilege-independent signal.
      [ -f "/Library/LaunchDaemons/com.tokenbase.collector.plist" ] && return 0
      if command -v launchctl >/dev/null 2>&1; then
        launchctl list 2>/dev/null | grep -q 'com\.tokenbase\.collector'
      else
        [ -f "$HOME/Library/LaunchAgents/com.tokenbase.collector.plist" ]
      fi
      ;;
    Linux)
      # System unit (installed by `service install --system`) lives at the system
      # path, invisible to the --user probe below; check it first.
      [ -f "/etc/systemd/system/tokenbase.service" ] && return 0
      if command -v systemctl >/dev/null 2>&1; then
        # is-active exits 0 (prints "active") only when the unit is running.
        systemctl --user is-active --quiet tokenbase.service 2>/dev/null
      else
        [ -f "$HOME/.config/systemd/user/tokenbase.service" ]
      fi
      ;;
    *)
      return 1
      ;;
  esac
}

# --- fetch + verify the (small) signed checksum manifest, up front ----------
# Fetching SHA256SUMS first (and verifying its signature) lets us compare the
# installed binary's checksum and skip the big binary download when it already
# matches latest/. Authenticity: verify the manifest's Ed25519 signature against
# the baked-in release key when openssl is available, so a tampered bucket can't
# feed us a matching binary+checksum pair. (Best-effort: openssl without Ed25519
# support falls back to checksum-only, same bar as most curl|sh installers.)
fetch_and_verify_sums() {
  download "$BASE/SHA256SUMS" "$sums" || err "could not fetch checksums for verification"
  if command -v openssl >/dev/null 2>&1; then
    if download "$BASE/SHA256SUMS.sig" "$sig" 2>/dev/null; then
      printf '%s\n' "$RELEASE_PUBKEY_PEM" > "$pub"
      base64 --decode < "$sig" > "$sig.bin" 2>/dev/null || base64 -d < "$sig" > "$sig.bin"
      if openssl pkeyutl -verify -pubin -inkey "$pub" \
           -rawin -in "$sums" -sigfile "$sig.bin" >/dev/null 2>&1; then
        printf 'Signature verified.\n'
      else
        rm -f "$sig.bin"
        err "manifest signature did not verify — refusing to install (untrusted download)"
      fi
      rm -f "$sig.bin"
    else
      printf 'Warning: no signature published; falling back to checksum only.\n' >&2
    fi
  fi
}

fetch_and_verify_sums
want="$(awk -v a="$asset" '$2 == a {print $1}' "$sums")"
[ -n "$want" ] || err "no checksum listed for $asset"

# --- compute current state vs desired ---------------------------------------
# binary_ok: an existing $dest whose checksum is byte-identical to latest/$asset.
binary_ok=0
if [ -f "$dest" ]; then
  cur="$(hash_cmd "$dest" 2>/dev/null | awk '{print $1}')"
  if [ "$cur" = "$want" ]; then binary_ok=1; fi
fi

if [ "$binary_ok" = 1 ]; then need_upgrade=0; else need_upgrade=1; fi
need_enroll=0
if [ "$enroll_wanted" = 1 ] && ! is_enrolled; then need_enroll=1; fi
# A running service must be RESTARTED after a binary upgrade so it re-execs the new
# code — otherwise install.sh silently leaves the OLD version running (and a stalled
# daemon can't self-update). So a service change is needed when one is wanted AND
# either it isn't running yet OR the binary just changed.
need_service=0
if [ "$service_wanted" = 1 ]; then
  if ! is_service_running || [ "$need_upgrade" = 1 ]; then need_service=1; fi
fi

# --- --check: report and exit without changing anything ---------------------
if [ "$check_only" = 1 ]; then
  if [ "$need_upgrade" = 0 ] && [ "$need_enroll" = 0 ] && [ "$need_service" = 0 ]; then
    printf 'Unchanged: tokenbase is already in the desired state; no changes needed.\n'
    exit 0
  fi
  printf 'Changed: a normal run would make these changes:\n'
  if [ "$need_upgrade" = 1 ]; then printf '  - install or upgrade the binary at %s\n' "$dest"; fi
  if [ "$need_enroll" = 1 ]; then printf '  - link (enroll) this device\n'; fi
  if [ "$need_service" = 1 ]; then
    if is_service_running; then
      printf '  - restart the background service onto the upgraded binary\n'
    else
      printf '  - install and start the background service\n'
    fi
  fi
  exit 10
fi

# --- idempotency gate: already converged, do nothing ------------------------
if [ "$need_upgrade" = 0 ] && [ "$need_enroll" = 0 ] && [ "$need_service" = 0 ]; then
  version="$("$dest" --version 2>/dev/null || echo '?')"
  printf '\n\342\234\223 Unchanged: tokenbase %s is already up to date at %s.\n' "$version" "$dest"
  exit 0
fi

printf 'Changed: converging tokenbase to the desired state ...\n'

# --- download + install, only when the binary is missing or out of date -----
if [ "$need_upgrade" = 1 ]; then
  printf 'Downloading %s ...\n' "$asset"
  download "$BASE/$asset" "$tmp" || err "download failed from $BASE/$asset"

  # --- verify SHA-256 against the published SHA256SUMS ----------------------
  got="$(hash_cmd "$tmp" | awk '{print $1}')"
  [ "$want" = "$got" ] || err "checksum mismatch for $asset (expected $want, got $got)"
  printf 'Checksum verified.\n'

  # --- verify it runs -------------------------------------------------------
  chmod +x "$tmp"
  if ! "$tmp" --version >/dev/null 2>&1; then
    err "downloaded binary failed to run (corrupt download or wrong arch?)"
  fi

  # --- install --------------------------------------------------------------
  mkdir -p "$target_dir" || err "could not create $target_dir"
  printf 'Installing to %s ...\n' "$dest"
  install -m 0755 "$tmp" "$dest" 2>/dev/null \
    || { cp "$tmp" "$dest" && chmod 0755 "$dest"; } \
    || err "could not write $dest (set TOKENBASE_INSTALL_DIR to a writable dir)"

  version="$("$dest" --version 2>/dev/null || echo '?')"
  printf '\n\342\234\223 Installed tokenbase %s to %s\n\n' "$version" "$dest"

  # --- PATH hint ------------------------------------------------------------
  case ":$PATH:" in
    *":$target_dir:"*) ;;
    *)
      printf 'Note: %s is not on your PATH. Add it:\n' "$target_dir"
      printf '  export PATH="%s:$PATH"\n\n' "$target_dir"
      ;;
  esac
else
  printf 'Binary at %s is already up to date; skipping download.\n' "$dest"
fi

# --- enroll + start, automatically ------------------------------------------
# Installing a telemetry collector means you want it linked and running, so the
# installer does both by default — one command, not three. Two shapes:
#
#   Fleet / headless (MDM, Ansible, CI base image) — ZERO interaction:
#     curl -fsSL https://tokenbasehq.com/install.sh | TOKENBASE_AUTH_KEY=tbek_… sh
#   Solo — one browser approval:
#     curl -fsSL https://tokenbasehq.com/install.sh | sh
#
# The key must prefix `sh`, not `curl`: in `a | b` a `VAR=x` prefix binds to `a`.
# Escape hatches: TOKENBASE_NO_ENROLL=1 (install only), TOKENBASE_NO_SERVICE=1
# (link but don't start the background service). `login` only opens the browser
# and polls — it reads no stdin — so it's safe to run from this piped installer.
# Idempotency: an already-linked device (device.json present) is never re-linked,
# so a converged re-run never calls `login`.
#
# Keeping the key out of logs (recommended for fleets): set TOKENBASE_AUTH_KEY_FILE
# to a path your secret manager mounts (e.g. /run/secrets/tokenbase-key) instead
# of putting the key inline — the secret then never appears on a command line, in
# shell history, or in MDM/CI logs. We also hand the key to the binary via the
# environment, never as an argv (argv is world-readable via /proc on Linux).
if [ "$enroll_wanted" = 0 ]; then
  printf '\nInstalled only (TOKENBASE_NO_ENROLL=1). Run `tokenbase login` when ready.\n'
elif is_enrolled; then
  printf '\nAlready linked (device.json present); skipping login.\n'
else
  # This script starts the background service right after linking (below), so tell
  # `login` to skip its manual "Run `tokenbase run`" hint — it would contradict the
  # "collecting" message. Only when a service is actually wanted; without one, the
  # hint is the correct next step. (The service isn't installed yet at link time, so
  # login can't detect it on its own.)
  [ "$service_wanted" = 1 ] && export TOKENBASE_INSTALL_MANAGED=1
  auth_key="${TOKENBASE_AUTH_KEY:-}"
  if [ -n "${TOKENBASE_AUTH_KEY_FILE:-}" ]; then
    [ -r "$TOKENBASE_AUTH_KEY_FILE" ] \
      || err "TOKENBASE_AUTH_KEY_FILE is not readable: $TOKENBASE_AUTH_KEY_FILE"
    IFS= read -r auth_key < "$TOKENBASE_AUTH_KEY_FILE" || true
  fi
  if [ -n "$auth_key" ]; then
    printf '\nLinking this device (headless) ...\n'
    TOKENBASE_AUTH_KEY="$auth_key" "$dest" login \
      || err "headless enrollment failed (check the key). Installed at $dest; retry with: TOKENBASE_AUTH_KEY=… tokenbase login"
  else
    printf '\nLinking this device — approve it in your browser ...\n'
    "$dest" login || true
  fi
fi

# Start the background service once linked, so the collector runs and starts at
# boot/login without a separate command. Gate on the computed service_wanted intent
# (not a re-derived NO_SERVICE check) so this matches exactly what --check predicts:
# under TOKENBASE_NO_ENROLL=1 the dry run reports no service change, so the real run
# must not start one either. is_enrolled is the actual-state guard (don't install a
# service for a device that didn't actually link). Skip when already running.
if [ "$service_wanted" = 1 ] && is_enrolled; then
  if [ "$need_service" = 0 ]; then
    printf '\nBackground collector already running; skipping service install.\n'
  else
    # need_service=1 = not running yet, OR the binary was upgraded and the running
    # service must restart onto it. `service install` unload+load+enable+kickstarts,
    # so it (re)starts the collector on the just-written binary either way.
    if is_service_running; then
      printf '\nUpgraded — restarting the background collector on the new version ...\n'
    else
      printf '\nStarting the background collector ...\n'
    fi
    "$dest" service install \
      || printf 'Note: could not start the service automatically — run: tokenbase service install\n' >&2
  fi
fi

if [ "$enroll_wanted" = 1 ] && is_enrolled; then
  printf '\n\342\234\223 tokenbase is installed, linked, and collecting.\n'
fi
