#!/bin/sh
# Perstat Agent installer (Linux/macOS).
#
#   PERSTAT_TOKEN=psag_… sh -c "$(curl -fsSL https://agent.perstat.io/install.sh)"
#
# Detects OS/arch, downloads the matching binary, verifies the ed25519 release
# signature over SHA256SUMS (against the public key pinned below) plus the
# binary's SHA-256 checksum,
# installs it to /opt/perstat-agent/bin (owned by the service user on Linux, so
# the agent can replace its own binary for root-free self-updates), registers the
# host (when PERSTAT_TOKEN is set) and sets it up as a persistent service:
#   - Linux: hardened systemd unit, runs as a dedicated non-root user
#   - macOS: LaunchDaemon (io.perstat.agent), runs at boot, KeepAlive
# Outbound-only, no remote code execution.
#
# Re-running on a host that ALREADY has an enrolled agent UPGRADES it in place
# (new binary + service, identity preserved), no re-enroll, no token needed.
# Force a fresh enroll with PERSTAT_FORCE_ENROLL=1.
#
# Env overrides (CI/tests): PERSTAT_BASE_URL, INSTALL_DIR, PERSTAT_SERVER,
# PERSTAT_NO_ENROLL=1 (binary only), PERSTAT_NO_SERVICE=1 (no service),
# PERSTAT_FORCE_ENROLL=1 (re-enroll even if state exists),
# PERSTAT_ALLOW_UNSIGNED=1 (accept a missing signature — dev builds only).
set -eu

# Distribution: agent.perstat.io, und nur das.
BASE_URL="${PERSTAT_BASE_URL:-https://agent.perstat.io}"
# Agent-owned bin dir (not /usr/local/bin): lets the non-root service user replace
# its own binary during a self-update, no root needed at runtime.
INSTALL_DIR="${INSTALL_DIR:-/opt/perstat-agent/bin}"
BIN_NAME="perstat-agent"
SVC_USER="perstat-agent"            # dedicated service user (Linux)
STATE_DIR="/var/lib/perstat-agent"
PLIST="/Library/LaunchDaemons/io.perstat.agent.plist"
UNIT="/etc/systemd/system/perstat-agent.service"

# ── Release-Signatur: der OEFFENTLICHE Schluessel, fest im Script ────────────
#
# SHA256SUMS allein beweist nur, dass Binary und Pruefsummenliste ZUEINANDER
# passen. Beide kommen vom selben Host — wer den kontrolliert, tauscht einfach
# beide, und die Pruefung unten meldet trotzdem "ok". Erst eine Signatur bindet
# die Liste an einen Schluessel, der NIE auf dem Server liegt (offline gehalten,
# s. deploy/scripts/build-agent.sh). Damit faellt ein untergeschobenes
# SHA256SUMS auf, auch wenn der Distributions-Host uebernommen wurde.
#
# Der Schluessel MUSS hier stehen und darf NICHT nebenher geladen werden: ein
# vom selben Host geholter Schluessel wuerde vom selben Angreifer mitgetauscht
# und bewiese gar nichts. Genau deshalb ist er hier eingefroren.
#
# Grenze, ehrlich benannt: wird dieses Script per `curl | sh` von demselben Host
# geholt, kann ein Angreifer mit Kontrolle ueber den Host auch das Script selbst
# ersetzen. Der Gewinn liegt dort, wo das Script FESTGEHALTEN wird — vendored in
# einer Ansible-Rolle, im Config-Management, in einem Runbook — und bei einer
# Kompromittierung, die nur die Artefakte trifft (Mirror, Cache, Objekt-Store).
# Fuer die Flotte danach uebernimmt das Agent-Binary dieselbe Pruefung im
# Selbstupdate (services/host-agent/src/update.rs, derselbe Schluessel).
#
# Gegenpruefen laesst sich der Wert unabhaengig auf https://agent.perstat.io/
# (Abschnitt "Release signing key").
PERSTAT_RELEASE_PUBKEY="16c7d1de9d67448c8882209041cfc5a7f060ce4c3be3cbab8b7544fed0d2a20b"

# Bekannt-gueltiger ed25519-Vektor: RFC 8032, §7.1, TEST 2 (Nachricht = ein Byte
# 0x72). Oeffentlich nachlesbar unter https://www.rfc-editor.org/rfc/rfc8032
# — nachschlagbar, nicht "vertrau uns". Wofuer der gut ist: s. ed25519_verifier().
RFC8032_TEST2_PUB="3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
RFC8032_TEST2_SIG="92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
# DER-Praefix einer ed25519-SubjectPublicKeyInfo; davor geklebt wird aus 32 rohen
# Schluesselbytes ein Key, den openssl einliest (spart eine PEM-Bastelei).
ED25519_SPKI_PREFIX="302a300506032b6570032100"

info() { printf '\033[1;34m›\033[0m %s\n' "$1"; }
ok() { printf '\033[1;32m✓\033[0m %s\n' "$1"; }
warn() { printf '\033[1;33m! %s\033[0m\n' "$1" >&2; }
die() { printf '\033[1;31m✗ %s\033[0m\n' "$1" >&2; exit 1; }

# Hex (als $1) → rohe Bytes auf stdout. Bewusst ohne xxd/perl/base64: xxd fehlt
# auf schlanken Debian-Images, perl auf Alpine. Reine Parameter-Expansion +
# printf kann jede POSIX-Shell (geprueft: dash, bash, busybox ash). Es geht um
# 32 bzw. 64 Byte, die Schleife ist billig.
hex2bin() {
  _hx="$1"
  while [ -n "$_hx" ]; do
    _rest="${_hx#??}"
    _byte="${_hx%"$_rest"}"
    _hx="$_rest"
    # SC2059 ist hier der Zweck, nicht der Fehler: die Oktal-Escape-Sequenz MUSS
    # im Format-String stehen, sonst wird sie nicht interpretiert, sondern
    # ausgegeben. $_byte ist per Muster-Pruefung oben auf Hex begrenzt.
    # shellcheck disable=SC2059
    printf "\\$(printf '%03o' "0x$_byte")"
  done
}

# Sucht ein openssl, das ed25519 WIRKLICH pruefen kann, und laesst es das an dem
# RFC-Vektor oben beweisen, statt Versionsnummern zu raten.
#
# Der Beweis ist sicherheitsrelevant, nicht Kosmetik: ohne ihn waere "openssl
# sagt nein" nicht von "openssl kann es nicht" zu unterscheiden — ein echter
# Angriff saehe aus wie ein altes openssl und wuerde durchgewunken. Mit ihm gilt:
# Vektor verifiziert → das Werkzeug funktioniert → ein Fehlschlag an der echten
# Signatur ist ein echter Fehlschlag, und wir brechen ab.
#
# Noetig ist das, weil `pkeyutl -rawin` erst ab OpenSSL 3.0 existiert. Gemessen:
#   Debian 12, Ubuntu 22.04/24.04, Alpine 3, Rocky 9 (OpenSSL 3.x) → kann es
#   Debian 11, Ubuntu 20.04 (OpenSSL 1.1.1)   → kann es NICHT ("unknown option")
#   macOS-System-openssl (LibreSSL 3.3.x)     → kann es NICHT (kein -rawin)
# Auf macOS greift daher der Homebrew-/MacPorts-Pfad, wenn dort eins liegt.
ed25519_verifier() {
  _probe="$tmp/probe"
  mkdir -p "$_probe"
  hex2bin "$ED25519_SPKI_PREFIX$RFC8032_TEST2_PUB" > "$_probe/pub.der"
  hex2bin "$RFC8032_TEST2_SIG" > "$_probe/sig"
  printf '\162' > "$_probe/msg"
  for _cand in openssl \
    /opt/homebrew/opt/openssl@3/bin/openssl \
    /usr/local/opt/openssl@3/bin/openssl \
    /opt/homebrew/bin/openssl \
    /usr/local/bin/openssl \
    /opt/local/bin/openssl; do
    command -v "$_cand" >/dev/null 2>&1 || continue
    "$_cand" pkey -pubin -inform DER -in "$_probe/pub.der" -out "$_probe/pub.pem" 2>/dev/null || continue
    "$_cand" pkeyutl -verify -pubin -inkey "$_probe/pub.pem" -rawin \
      -in "$_probe/msg" -sigfile "$_probe/sig" >/dev/null 2>&1 || continue
    printf '%s' "$_cand"
    return 0
  done
  return 1
}

# ── OS/arch → asset ────────────────────────────────────────────────────────
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
  Linux)
    case "$arch" in
      x86_64 | amd64) asset="perstat-agent-linux-x86_64" ;;
      aarch64 | arm64) asset="perstat-agent-linux-aarch64" ;;
      *) die "unsupported architecture: $arch" ;;
    esac
    ;;
  Darwin) asset="perstat-agent-macos-universal" ;;
  *) die "unsupported OS: $os (Windows: coming soon via MSI)" ;;
esac

command -v curl >/dev/null 2>&1 || die "curl is required"

# sudo command if available (empty when root). Only required where root is truly
# needed (enroll + service), not for a binary-only install into a writable
# INSTALL_DIR (e.g. CI/tests with INSTALL_DIR=/tmp/…).
# Umgebungsvariablen: PERSTAT_* ist ab dem Rebrand der kanonische Name. Die
# DATARGO_*-Namen werden weiterhin angenommen, weil sie in bestehenden
# Runbooks, Ansible-Rollen und Shell-Historien stehen und ein stiller Fehlschlag
# hier teuer waere: das Script liefe durch und liesse den Host unregistriert.
: "${PERSTAT_TOKEN:=${PERSTAT_TOKEN:-}}"
: "${PERSTAT_SERVER:=${PERSTAT_SERVER:-}}"
: "${PERSTAT_BASE_URL:=${PERSTAT_BASE_URL:-}}"
: "${PERSTAT_NO_ENROLL:=${PERSTAT_NO_ENROLL:-}}"
: "${PERSTAT_NO_SERVICE:=${PERSTAT_NO_SERVICE:-}}"
: "${PERSTAT_FORCE_ENROLL:=${PERSTAT_FORCE_ENROLL:-}}"

SUDO_CMD=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
  SUDO_CMD="sudo"
fi

# Upgrade-Modus: existiert bereits ein enrollter Agent (State-Datei), NICHT neu
# registrieren (sonst zweite Identität im Cockpit), nur Binary + Service
# aktualisieren und die Identität behalten. (test via sudo, da $STATE_DIR 750 ist.)
STATE_FILE="$STATE_DIR/agent.toml"
UPGRADE=0
if [ -z "${PERSTAT_FORCE_ENROLL:-}" ] && $SUDO_CMD test -f "$STATE_FILE" 2>/dev/null; then
  UPGRADE=1
fi

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM

# ── Download ───────────────────────────────────────────────────────────────
info "Downloading $asset from $BASE_URL …"
curl -fsSL -o "$tmp/$BIN_NAME" "$BASE_URL/$asset" || die "download failed"
curl -fsSL -o "$tmp/SHA256SUMS" "$BASE_URL/SHA256SUMS" || die "SHA256SUMS download failed"
# Die Signatur wird hier NICHT hart erzwungen geladen. Ob ihr Fehlen ein Abbruch
# ist, entscheidet der Block unten — das haengt davon ab, ob wir ueberhaupt
# pruefen koennen, und diese Unterscheidung gehoert an eine Stelle.
sig_hex=""
if curl -fsSL -o "$tmp/SHA256SUMS.sig" "$BASE_URL/SHA256SUMS.sig" 2>/dev/null; then
  sig_hex="$(tr -d '[:space:]' < "$tmp/SHA256SUMS.sig")"
fi

# ── Verify release signature ───────────────────────────────────────────────
# Reihenfolge mit Absicht: erst die Signatur ueber SHA256SUMS, dann der Hash des
# Binaries GEGEN diese Liste. Andersherum wuerde man die Pruefsumme gegen eine
# Liste halten, deren Herkunft noch niemand belegt hat.
info "Verifying release signature …"
signature_checked=0
OSSL="$(ed25519_verifier || true)"
if [ -z "$OSSL" ]; then
  # Kein Abbruch: hier fail-closed zu gehen wuerde die Installation auf jedem
  # Debian 11 / Ubuntu 20.04 / macOS-ohne-Homebrew abwuergen — fuer einen
  # Angreifer waere das kein Hindernis, nur fuer alle anderen. Also laut sein
  # und weiterlaufen; was noch traegt (TLS + Pruefsumme), steht in der Meldung.
  warn "No openssl with ed25519 support found — release signature NOT verified."
  warn "  Falling back to TLS + SHA-256 checksum only."
  warn "  For the full check: apt install openssl (>= 3.0) / brew install openssl@3, then re-run."
elif [ -z "$sig_hex" ]; then
  # Fehlende Signatur bei pruefbarem openssl ist KEIN "dann eben ohne": genau so
  # saehe der billigste Angriff aus — Signatur wegnehmen, Rest tauschen.
  [ -n "${PERSTAT_ALLOW_UNSIGNED:-}" ] \
    || die "SHA256SUMS.sig missing on $BASE_URL — refusing to install. A stripped signature is exactly what a tampered mirror looks like. Unsigned dev builds: PERSTAT_ALLOW_UNSIGNED=1."
  warn "SHA256SUMS.sig missing — continuing on checksum only (PERSTAT_ALLOW_UNSIGNED=1)."
else
  # Muell frueh abfangen: sonst stolpert hex2bin ueber Nicht-Hex und die Meldung
  # waere ein printf-Fehler statt einer Aussage.
  case "$sig_hex" in
    *[!0-9A-Fa-f]*) die "SHA256SUMS.sig is not hex — refusing to install." ;;
  esac
  [ "${#sig_hex}" -eq 128 ] \
    || die "SHA256SUMS.sig has ${#sig_hex} hex chars, expected 128 (64-byte ed25519) — refusing to install."
  hex2bin "$ED25519_SPKI_PREFIX$PERSTAT_RELEASE_PUBKEY" > "$tmp/relkey.der"
  "$OSSL" pkey -pubin -inform DER -in "$tmp/relkey.der" -out "$tmp/relkey.pem" 2>/dev/null \
    || die "could not load the pinned release key — is this install.sh intact?"
  hex2bin "$sig_hex" > "$tmp/SHA256SUMS.sig.bin"
  "$OSSL" pkeyutl -verify -pubin -inkey "$tmp/relkey.pem" -rawin \
    -in "$tmp/SHA256SUMS" -sigfile "$tmp/SHA256SUMS.sig.bin" >/dev/null 2>&1 \
    || die "RELEASE SIGNATURE INVALID — SHA256SUMS from $BASE_URL is not signed by the Perstat release key. Nothing was installed. (If the key was rotated, fetch a current install.sh.)"
  signature_checked=1
  ok "release signature ok (ed25519, key $(printf '%s' "$PERSTAT_RELEASE_PUBKEY" | cut -c1-16)…)"
fi

# ── Verify checksum ────────────────────────────────────────────────────────
info "Verifying checksum …"
want="$(awk -v a="$asset" '$2 == a { print $1 }' "$tmp/SHA256SUMS")"
[ -n "$want" ] || die "no checksum for $asset in SHA256SUMS"
if command -v sha256sum >/dev/null 2>&1; then
  got="$(sha256sum "$tmp/$BIN_NAME" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then
  got="$(shasum -a 256 "$tmp/$BIN_NAME" | awk '{print $1}')"
else
  die "neither sha256sum nor shasum available"
fi
[ "$want" = "$got" ] || die "checksum mismatch (expected $want, got $got)"
chmod +x "$tmp/$BIN_NAME"
if [ "$signature_checked" = "1" ]; then
  ok "checksum ok (signed release, verified)"
else
  ok "checksum ok (UNVERIFIED release — signature not checked, see warning above)"
fi

# ── Install ────────────────────────────────────────────────────────────────
# sudo only if INSTALL_DIR (or its parent) is not writable.
INSTALL_SUDO=""
if [ -d "$INSTALL_DIR" ]; then
  [ -w "$INSTALL_DIR" ] || INSTALL_SUDO="$SUDO_CMD"
else
  [ -w "$(dirname "$INSTALL_DIR")" ] || INSTALL_SUDO="$SUDO_CMD"
fi
info "Installing to $INSTALL_DIR/$BIN_NAME …"
$INSTALL_SUDO mkdir -p "$INSTALL_DIR"
$INSTALL_SUDO mv "$tmp/$BIN_NAME" "$INSTALL_DIR/$BIN_NAME"
BIN="$INSTALL_DIR/$BIN_NAME"
ok "$("$BIN" --version 2>/dev/null || echo "$BIN_NAME installed")"

# No token AND no existing install: binary only, no enroll/service. (In upgrade
# mode we continue, identity already exists, we just refresh binary + service.)
if { [ -z "${PERSTAT_TOKEN:-}" ] || [ -n "${PERSTAT_NO_ENROLL:-}" ]; } && [ "$UPGRADE" = "0" ]; then
  printf '\n'
  info "Binary installed. Register + set up the service with your token:"
  printf '    PERSTAT_TOKEN=psag_… %s\n' "sh -c \"\$(curl -fsSL $BASE_URL/install.sh)\""
  exit 0
fi

# From here on root is required (enroll writes to /var/lib, service setup).
if [ "$(id -u)" -ne 0 ] && [ -z "$SUDO_CMD" ]; then
  die "root/sudo required for registration + service setup."
fi

# ── Service user (Linux: dedicated, non-root) ────────────────────────────────
if [ "$os" = "Linux" ] && [ "${PERSTAT_NO_SERVICE:-}" != "1" ]; then
  if ! id "$SVC_USER" >/dev/null 2>&1; then
    info "Creating service user $SVC_USER (system, non-login) …"
    $SUDO_CMD useradd --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER" 2>/dev/null \
      || $SUDO_CMD adduser --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER" 2>/dev/null \
      || true
  fi
fi

# ── Register (enroll), übersprungen im Upgrade (Identität bleibt erhalten) ───
if [ "$UPGRADE" = "1" ]; then
  ok "Existing identity found ($STATE_FILE), upgrading without re-enrollment."
else
  info "Registering host …"
  set -- enroll
  [ -n "${PERSTAT_SERVER:-}" ] && set -- "$@" --server "$PERSTAT_SERVER"
  $SUDO_CMD env PERSTAT_TOKEN="$PERSTAT_TOKEN" "$BIN" "$@" \
    || die "registration failed, is the token valid / not expired?"
  ok "Host registered."
fi
# Hand the state AND the bin dir over to the service user (Linux), enroll ran as
# root. Owning the bin dir lets the agent replace its own binary on update (atomic
# rename within a user-owned dir) without root.
if [ "$os" = "Linux" ] && id "$SVC_USER" >/dev/null 2>&1; then
  $SUDO_CMD chown -R "$SVC_USER":"$SVC_USER" "$STATE_DIR" 2>/dev/null \
    || $SUDO_CMD chown -R "$SVC_USER" "$STATE_DIR" 2>/dev/null || true
  $SUDO_CMD chmod 750 "$STATE_DIR" 2>/dev/null || true
  $SUDO_CMD chown -R "$SVC_USER":"$SVC_USER" "$INSTALL_DIR" 2>/dev/null \
    || $SUDO_CMD chown -R "$SVC_USER" "$INSTALL_DIR" 2>/dev/null || true
fi

# ── Service setup ────────────────────────────────────────────────────────────
if [ "${PERSTAT_NO_SERVICE:-}" = "1" ]; then
  printf '\n'
  info "Service setup skipped (PERSTAT_NO_SERVICE=1). Run manually: $BIN_NAME run"
  exit 0
fi

if [ "$os" = "Linux" ]; then
  command -v systemctl >/dev/null 2>&1 || {
    info "no systemd found, start manually: $BIN_NAME run"
    exit 0
  }
  info "Installing systemd unit (hardened, User=$SVC_USER) …"
  $SUDO_CMD sh -c "cat > '$UNIT'" <<UNITEOF
[Unit]
Description=Perstat Agent (host monitoring)
Documentation=https://agent.perstat.io
After=network-online.target
Wants=network-online.target
# Crash-loop guard: if a (self-updated) binary fails to start repeatedly, stop
# instead of spinning. The self-updater's health-gate rolls back before this hits.
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=simple
User=$SVC_USER
ExecStart=$BIN run
# SIGHUP = graceful re-exec into the (swapped) binary, same PID, used by the
# root-free self-updater to restart without systemctl.
Restart=on-failure
RestartSec=5
# Hardening: the agent is outbound-only and read-only.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
StateDirectory=perstat-agent
# Self-Updater: ProtectSystem=strict macht ALLES read-only, das eigene
# bin-Verzeichnis muss fuer den atomaren Binary-Tausch beschreibbar sein
# (Vorfall 2026-07-12: erster Flotten-Self-Update scheiterte an EROFS).
ReadWritePaths=/opt/perstat-agent/bin
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
SystemCallFilter=@system-service

[Install]
WantedBy=multi-user.target
UNITEOF
  $SUDO_CMD systemctl daemon-reload
  $SUDO_CMD systemctl enable perstat-agent >/dev/null 2>&1 || true
  # restart (NICHT nur enable --now): beim Upgrade läuft sonst der alte Prozess
  # weiter (enable --now startet einen bereits aktiven Dienst nicht neu), das
  # neue /opt-Binary würde installiert, aber nie aktiviert.
  $SUDO_CMD systemctl restart perstat-agent

  ok "Service running: $(systemctl is-active perstat-agent 2>/dev/null || echo unknown)  (logs: journalctl -u perstat-agent -f)"

elif [ "$os" = "Darwin" ]; then
  info "Installing LaunchDaemon (io.perstat.agent) …"
  $SUDO_CMD mkdir -p /var/log
  $SUDO_CMD sh -c "cat > '$PLIST'" <<PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>io.perstat.agent</string>
  <key>ProgramArguments</key>
  <array>
    <string>$BIN</string>
    <string>run</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>ProcessType</key><string>Background</string>
  <key>StandardOutPath</key><string>/var/log/perstat-agent.log</string>
  <key>StandardErrorPath</key><string>/var/log/perstat-agent.log</string>
</dict>
</plist>
PLISTEOF
  $SUDO_CMD chmod 644 "$PLIST"
  # Reload cleanly if already present (idempotent).
  $SUDO_CMD launchctl bootout system/io.perstat.agent 2>/dev/null || true
  $SUDO_CMD launchctl bootstrap system "$PLIST" 2>/dev/null \
    || $SUDO_CMD launchctl load -w "$PLIST" 2>/dev/null \
    || die "launchctl could not load the service"
  $SUDO_CMD launchctl enable system/io.perstat.agent 2>/dev/null || true
  $SUDO_CMD launchctl kickstart -k system/io.perstat.agent 2>/dev/null || true
  ok "Service running (logs: tail -f /var/log/perstat-agent.log)"
fi

printf '\n'
if [ "$UPGRADE" = "1" ]; then
  ok "Upgrade complete, agent now running $("$BIN" --version 2>/dev/null || echo "$BIN_NAME") (identity preserved)."
else
  ok "Done, the Perstat Agent is running and reporting to the cockpit."
fi
