#!/usr/bin/env bash
# Approvalens server agent installer.
#
#   curl -fsSL https://approvalens.com/agent/install.sh | sudo bash -s -- --token <TOKEN>
#
# What it does, in order (it is meant to be read; --dry-run prints every step without doing it):
#   1. checks it runs as root on Linux with systemd, detects the CPU architecture (amd64 / arm64);
#   2. downloads the agent binary and SHA256SUMS from approvalens.com and verifies the checksum
#      (or, with --binary, installs a binary you built yourself from the published source);
#   3. creates the system user "approvalens-agent" (no shell, no home) unless --root is given;
#   4. installs /usr/local/bin/approvalens-agent and writes /etc/approvalens-agent.yaml (mode 0600);
#   5. installs a hardened systemd unit (read-only filesystem, no new privileges, 5% CPU, 64 MB RAM)
#      and starts it.
#
# Options:
#   --token TOKEN      the server token from your Approvalens account (required on first install)
#   --url URL          where reports go (default https://approvalens.com)
#   --web-root PATH    a web root to watch for new / changed PHP, JS and HTML files (repeatable)
#   --root             run the agent as root instead of its own user: adds other users' crontabs,
#                      /root/.ssh and the executable path of every process (read-only capabilities)
#   --binary FILE      install this binary instead of downloading one (build it from the source:
#                      https://approvalens.com/agent)
#   --dry-run          print what would be done, change nothing
#   --uninstall        stop the agent and remove everything this script installed
#
# The agent only reads system statistics and sends them out over HTTPS. It never executes
# commands, never runs code it receives and has no listening socket.
set -euo pipefail

VERSION="${AGENT_VERSION:-0.1.0}"
URL="https://approvalens.com"
TOKEN=""
WEB_ROOTS=()
AS_ROOT=0
UNINSTALL=0
DRY=0
LOCAL_BIN=""

BIN=/usr/local/bin/approvalens-agent
CONF=/etc/approvalens-agent.yaml
UNIT=/etc/systemd/system/approvalens-agent.service
STATE=/var/lib/approvalens-agent
USER_NAME=approvalens-agent

say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
# run: do it, or with --dry-run only show it.
run() { if [ "$DRY" = 1 ]; then printf '    [dry-run] %s\n' "$*"; else "$@"; fi; }
# write FILE MODE: content from stdin.
write() {
  if [ "$DRY" = 1 ]; then
    printf '    [dry-run] write %s (mode %s):\n' "$1" "$2"
    sed 's/^/      | /' | sed 's/token: ".*"/token: "<hidden>"/'
  else
    (umask 077; cat > "$1")
    chmod "$2" "$1"
  fi
}

while [ $# -gt 0 ]; do
  case "$1" in
    --token) TOKEN="${2:-}"; shift 2 ;;
    --token=*) TOKEN="${1#*=}"; shift ;;
    --url) URL="${2:-}"; shift 2 ;;
    --url=*) URL="${1#*=}"; shift ;;
    --web-root) WEB_ROOTS+=("${2:-}"); shift 2 ;;
    --web-root=*) WEB_ROOTS+=("${1#*=}"); shift ;;
    --binary) LOCAL_BIN="${2:-}"; shift 2 ;;
    --binary=*) LOCAL_BIN="${1#*=}"; shift ;;
    --root) AS_ROOT=1; shift ;;
    --dry-run) DRY=1; shift ;;
    --uninstall) UNINSTALL=1; shift ;;
    -h|--help) echo "usage: install.sh --token TOKEN [--url URL] [--web-root PATH]... [--root] [--binary FILE] [--dry-run] | --uninstall [--dry-run]"; exit 0 ;;
    *) die "unknown option: $1 (see --help)" ;;
  esac
done
URL="${URL%/}"
[ "$DRY" = 1 ] && say "Dry run: nothing will be changed"

if [ "$DRY" = 0 ]; then
  [ "$(id -u)" -eq 0 ] || die "run as root (sudo bash -s -- --token ...)"
fi
[ "$(uname -s)" = Linux ] || die "the agent runs on Linux only"
if ! command -v systemctl >/dev/null || [ ! -d /run/systemd/system ]; then die "systemd is required"; fi

if [ "$UNINSTALL" = 1 ]; then
  say "Removing the Approvalens agent"
  run systemctl disable --now approvalens-agent.service || true
  run rm -f "$UNIT" "$BIN" "$CONF"
  run rm -rf "$STATE"
  run systemctl daemon-reload
  if id "$USER_NAME" >/dev/null 2>&1; then run userdel "$USER_NAME" || true; fi
  say "Done. Delete the server in your Approvalens account to remove its data and token."
  exit 0
fi

# Reinstall / upgrade without --token keeps the token already configured.
if [ -z "$TOKEN" ] && [ -r "$CONF" ]; then
  TOKEN="$(sed -n 's/^token:[[:space:]]*"\{0,1\}\([A-Za-z0-9_-]*\)"\{0,1\}[[:space:]]*$/\1/p' "$CONF" | head -n1)"
fi
[ -n "$TOKEN" ] || die "--token is required (copy the install line from your Approvalens account)"
case "$TOKEN" in *[!A-Za-z0-9_-]*) die "the token has unexpected characters" ;; esac
case "$URL" in https://*|http://127.0.0.1*|http://localhost*) ;; *) die "--url must start with https://" ;; esac
for r in "${WEB_ROOTS[@]+"${WEB_ROOTS[@]}"}"; do
  case "$r" in /*) ;; *) die "--web-root must be an absolute path: $r" ;; esac
  case "$r" in *[\"\\]*) die "--web-root contains a quote or backslash: $r" ;; esac
done

case "$(uname -m)" in
  x86_64|amd64) ARCH=amd64 ;;
  aarch64|arm64) ARCH=arm64 ;;
  *) die "unsupported architecture $(uname -m) (amd64 and arm64 are built)" ;;
esac
NAME="approvalens-agent-linux-$ARCH"

if command -v sha256sum >/dev/null; then sha() { sha256sum "$1" | cut -d' ' -f1; }
else sha() { shasum -a 256 "$1" | cut -d' ' -f1; }; fi

TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
if [ -n "$LOCAL_BIN" ]; then
  [ -f "$LOCAL_BIN" ] || die "--binary: no such file: $LOCAL_BIN"
  say "Using your own binary $LOCAL_BIN (sha256 $(sha "$LOCAL_BIN"))"
  "$LOCAL_BIN" version >/dev/null 2>&1 || die "--binary: $LOCAL_BIN does not run here (wrong architecture?)"
  cp "$LOCAL_BIN" "$TMP/$NAME"
else
  if command -v curl >/dev/null; then fetch() { curl -fsSL --proto '=https,http' -o "$2" "$1"; }
  elif command -v wget >/dev/null; then fetch() { wget -q -O "$2" "$1"; }
  else die "curl or wget is required"; fi
  say "Downloading approvalens-agent $VERSION ($ARCH) from $URL/agent/$VERSION/"
  fetch "$URL/agent/$VERSION/$NAME" "$TMP/$NAME" || die "download failed: $URL/agent/$VERSION/$NAME"
  fetch "$URL/agent/$VERSION/SHA256SUMS" "$TMP/SHA256SUMS" || die "download failed: $URL/agent/$VERSION/SHA256SUMS"
  WANT="$(awk -v n="$NAME" '$2 == n || $2 == "*"n {print $1}' "$TMP/SHA256SUMS")"
  GOT="$(sha "$TMP/$NAME")"
  [ -n "$WANT" ] && [ "$WANT" = "$GOT" ] || die "checksum mismatch for $NAME (expected ${WANT:-none}, got $GOT)"
  say "Checksum OK ($GOT). Build it yourself from the source and compare: $URL/agent"
fi

if [ "$AS_ROOT" = 1 ]; then
  RUN_USER=root
else
  RUN_USER="$USER_NAME"
  if ! id "$USER_NAME" >/dev/null 2>&1; then
    say "Creating system user $USER_NAME"
    NOLOGIN="$(command -v nologin || echo /usr/sbin/nologin)"
    run useradd --system --no-create-home --home-dir /nonexistent --shell "$NOLOGIN" "$USER_NAME"
  fi
fi

run systemctl stop approvalens-agent.service 2>/dev/null || true
say "Installing $BIN"
run install -m 0755 -o root -g root "$TMP/$NAME" "$BIN"

say "Writing $CONF"
{
  echo "# Approvalens server agent. Docs: $URL/agent"
  echo "token: \"$TOKEN\""
  echo "url: $URL"
  if [ ${#WEB_ROOTS[@]} -gt 0 ]; then
    echo "web_roots:"
    for r in "${WEB_ROOTS[@]}"; do echo "  - \"$r\""; done
  else
    echo "# web_roots:            # watch PHP / JS / HTML files for changes (names, times and sizes only)"
    echo "#   - /var/www/html"
  fi
  echo "# sample_interval: 60s      # local reading"
  echo "# heartbeat_interval: 5m    # a few numbers"
  echo "# rollup_interval: 15m      # chart data"
} | write "$CONF" 0600
run chown "$RUN_USER" "$CONF"

if [ "$AS_ROOT" = 1 ]; then
  # Root, but only with the two read-only capabilities the extra checks need.
  CAPS="CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_SYS_PTRACE"
else
  CAPS="CapabilityBoundingSet="
fi

say "Installing $UNIT"
write "$UNIT" 0644 <<EOF
[Unit]
Description=Approvalens server agent (reads system statistics, reports over HTTPS)
Documentation=$URL/agent
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=$RUN_USER
ExecStart=$BIN run --config $CONF
Restart=always
RestartSec=15
StateDirectory=approvalens-agent
StateDirectoryMode=0700
# Hardening: everything read-only except its own state directory, no privilege gain.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
ReadOnlyPaths=/
$CAPS
AmbientCapabilities=
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
UMask=0077
# Footprint caps
CPUQuota=5%
MemoryMax=64M
Nice=10
IOSchedulingClass=idle

[Install]
WantedBy=multi-user.target
EOF

run systemctl daemon-reload
run systemctl enable --now approvalens-agent.service
if [ "$DRY" = 1 ]; then
  say "Dry run finished: nothing was changed."
  exit 0
fi
sleep 2
if systemctl is-active --quiet approvalens-agent.service; then
  say "The agent is running as $RUN_USER. Your account shows it as connected within a minute."
  echo "    See what it sends:   sudo -u $RUN_USER $BIN check --config $CONF"
  echo "    Logs:                journalctl -u approvalens-agent -f"
  echo "    Remove:              curl -fsSL $URL/agent/install.sh | sudo bash -s -- --uninstall"
else
  systemctl status approvalens-agent.service --no-pager || true
  die "the agent did not start (see the status above)"
fi
