#!/bin/sh set -e # Termique Agent Installer # Usage: curl -fsSL https://get.termique.app | TERMIQUE_TOKEN=xxx TERMIQUE_SERVER_ID=xxx sh # Optional: TERMIQUE_AUTO_UPDATE=false skips installing the daily auto-update # timer entirely (the main agent binary/service install is unaffected). # Optional: TERMIQUE_SECURITY_EVENTS=true opts into security-event capture # (auth attempts, ban events, error-rate spikes — disabled by default, see # NFR-2). Optional: TERMIQUE_REVERSE_PROXY_LOG_PATH=/path/to/access.log # additionally watches a reverse-proxy log for http_error_spike detection. BINARY_NAME="termique-agent" GITHUB_REPO="termique-app/agent" INSTALL_DIR="/usr/local/bin" CONFIG_DIR="${HOME}/.config/termique-agent" API_URL="https://monitor.termique.app" INTERVAL=30 # ── Validate required env vars ─────────────────────────────────────────────── if [ -z "${TERMIQUE_TOKEN}" ]; then echo "Error: TERMIQUE_TOKEN is not set." >&2 echo "Usage: curl -fsSL https://get.termique.app | TERMIQUE_TOKEN=xxx TERMIQUE_SERVER_ID=xxx sh" >&2 exit 1 fi if [ -z "${TERMIQUE_SERVER_ID}" ]; then echo "Error: TERMIQUE_SERVER_ID is not set." >&2 echo "Usage: curl -fsSL https://get.termique.app | TERMIQUE_TOKEN=xxx TERMIQUE_SERVER_ID=xxx sh" >&2 exit 1 fi # ── Detect architecture ─────────────────────────────────────────────────────── ARCH=$(uname -m) case "${ARCH}" in x86_64) ARCH_SLUG="amd64" ;; aarch64 | arm64) ARCH_SLUG="arm64" ;; *) echo "Error: Unsupported architecture: ${ARCH}" >&2 echo "Termique agent currently supports x86_64 and aarch64." >&2 exit 1 ;; esac BINARY_FILENAME="${BINARY_NAME}-linux-${ARCH_SLUG}" BASE_URL="https://github.com/${GITHUB_REPO}/releases/latest/download" BINARY_URL="${BASE_URL}/${BINARY_FILENAME}" CHECKSUMS_URL="${BASE_URL}/${BINARY_FILENAME}.sha256" echo "Detected architecture: ${ARCH} (${ARCH_SLUG})" echo "Downloading Termique agent..." # ── Download binary ─────────────────────────────────────────────────────────── TMP_DIR=$(mktemp -d) TMP_BINARY="${TMP_DIR}/${BINARY_FILENAME}" TMP_CHECKSUMS="${TMP_DIR}/checksums.txt" cleanup() { rm -rf "${TMP_DIR}" } trap cleanup EXIT if command -v curl > /dev/null 2>&1; then curl -fsSL -o "${TMP_BINARY}" "${BINARY_URL}" curl -fsSL -o "${TMP_CHECKSUMS}" "${CHECKSUMS_URL}" elif command -v wget > /dev/null 2>&1; then wget -q -O "${TMP_BINARY}" "${BINARY_URL}" wget -q -O "${TMP_CHECKSUMS}" "${CHECKSUMS_URL}" else echo "Error: Neither curl nor wget is available. Please install one and retry." >&2 exit 1 fi # ── Verify SHA-256 checksum ─────────────────────────────────────────────────── echo "Verifying checksum..." EXPECTED_HASH=$(grep "${BINARY_FILENAME}" "${TMP_CHECKSUMS}" | awk '{ print $1 }') if [ -z "${EXPECTED_HASH}" ]; then echo "Error: Could not find checksum for ${BINARY_FILENAME} in checksums.txt." >&2 exit 1 fi if command -v sha256sum > /dev/null 2>&1; then ACTUAL_HASH=$(sha256sum "${TMP_BINARY}" | awk '{ print $1 }') elif command -v shasum > /dev/null 2>&1; then ACTUAL_HASH=$(shasum -a 256 "${TMP_BINARY}" | awk '{ print $1 }') else echo "Error: Neither sha256sum nor shasum is available. Cannot verify checksum." >&2 exit 1 fi if [ "${ACTUAL_HASH}" != "${EXPECTED_HASH}" ]; then echo "Error: Checksum verification failed." >&2 echo " Expected: ${EXPECTED_HASH}" >&2 echo " Actual: ${ACTUAL_HASH}" >&2 exit 1 fi echo "Checksum verified." # Honour the pinned user when re-invoked by the root update timer; fall back # to the invoking user on a normal first install. CURRENT_USER="${TERMIQUE_SERVICE_USER:-$(id -un)}" # ── Install binary ──────────────────────────────────────────────────────────── INSTALL_PATH="${INSTALL_DIR}/${BINARY_NAME}" # Install with an explicit root owner rather than `mv`, for the same reason # the update script below does: mv from $TMP_DIR is a rename() on a # single-filesystem host and keeps the installing user as owner, and even # across filesystems GNU mv preserves ownership when running as root. That # would leave /usr/local/bin/termique-agent writable by the unprivileged # account that ran the installer — while the daily update timer executes it # as root (`"$INSTALL_PATH" --version`), which is a local privilege # escalation to root. if [ "$(id -u)" = "0" ]; then install -o root -g root -m 755 "${TMP_BINARY}" "${INSTALL_PATH}" else echo "Installing to ${INSTALL_PATH} (requires sudo)..." sudo install -o root -g root -m 755 "${TMP_BINARY}" "${INSTALL_PATH}" fi rm -f "${TMP_BINARY}" echo "Binary installed to ${INSTALL_PATH}." # ── Write config ────────────────────────────────────────────────────────────── mkdir -p "${CONFIG_DIR}" chmod 700 "${CONFIG_DIR}" # The config holds a plaintext bearer token — write it with a tight umask so # it is never group/world-readable, then enforce 0600 explicitly. ( umask 077 cat > "${CONFIG_DIR}/config.toml" << TOML_EOF token = "${TERMIQUE_TOKEN}" server_id = "${TERMIQUE_SERVER_ID}" api_url = "${API_URL}" interval = ${INTERVAL} TOML_EOF # Security-event capture is opt-in and default-unset (NFR-2) — mirrors the # TERMIQUE_AUTO_UPDATE env-gate pattern above but inverted: no env var set # means no line written, and the agent's own `false` default applies. if [ "${TERMIQUE_SECURITY_EVENTS}" = "true" ]; then echo 'security_events_enabled = true' >> "${CONFIG_DIR}/config.toml" fi if [ -n "${TERMIQUE_REVERSE_PROXY_LOG_PATH}" ]; then printf 'reverse_proxy_log_path = "%s" ' "${TERMIQUE_REVERSE_PROXY_LOG_PATH}" >> "${CONFIG_DIR}/config.toml" fi ) chmod 600 "${CONFIG_DIR}/config.toml" echo "Config written to ${CONFIG_DIR}/config.toml (mode 600)." # ── Install auto-update script (skippable via TERMIQUE_AUTO_UPDATE=false) ──── UPDATE_SCRIPT_PATH="/usr/local/sbin/termique-agent-update" if [ "${TERMIQUE_AUTO_UPDATE}" = "false" ]; then echo "TERMIQUE_AUTO_UPDATE=false — skipping auto-update script and timer setup." else TMP_UPDATE="${TMP_DIR}/termique-agent-update" # Bake credentials and HOME into the update script so the daily systemd # timer can re-run it as root without any interactive input. The rest of # the script (below, in a quoted heredoc) bakes in nothing else — it reads # the exported env vars from this preamble and computes everything else at # run time, once a day, when the timer actually fires. cat > "${TMP_UPDATE}" << UPDATER_EOF #!/bin/sh set -e export TERMIQUE_TOKEN="${TERMIQUE_TOKEN}" export TERMIQUE_SERVER_ID="${TERMIQUE_SERVER_ID}" export HOME="${HOME}" # Pin the account the service runs as. The updater re-runs this installer as # root, so `id -un` would resolve to "root" and silently promote the agent # from the installing user to root while it still reads its config from that # user's home — where that user can rewrite api_url or point the log tailer # at an arbitrary file. export TERMIQUE_SERVICE_USER="${CURRENT_USER}" UPDATER_EOF # Quoted heredoc delimiter — nothing in this body is expanded by *this* # (the installer's) shell; it is written byte-for-byte and only evaluated # later, each time the update script itself actually runs. cat >> "${TMP_UPDATE}" << 'UPDATER_BODY_EOF' CONFIG_FILE="$HOME/.config/termique-agent/config.toml" INSTALL_PATH="/usr/local/bin/termique-agent" BACKUP_PATH="/usr/local/bin/termique-agent.previous" API_URL="https://monitor.termique.app" # Runtime opt-out sentinel (FR-5.2): editing config.toml disables updates # going forward without touching systemd units. Checked before any network # call. if [ -f "$CONFIG_FILE" ] && grep -Eq '^[[:space:]]*auto_update[[:space:]]*=[[:space:]]*false[[:space:]]*$' "$CONFIG_FILE"; then echo "termique-agent-update: auto_update=false in config.toml — skipping." exit 0 fi # Detect architecture — duplicated from the installer intentionally. This # baked file is standalone and does not re-source it. ARCH=$(uname -m) case "$ARCH" in x86_64) ARCH_SLUG="amd64" ;; aarch64 | arm64) ARCH_SLUG="arm64" ;; *) echo "termique-agent-update: unsupported architecture $ARCH — skipping." >&2 exit 0 ;; esac if [ ! -x "$INSTALL_PATH" ]; then echo "termique-agent-update: $INSTALL_PATH not found — skipping." >&2 exit 0 fi INSTALLED_VERSION=$("$INSTALL_PATH" --version 2>/dev/null) || INSTALLED_VERSION="" VERSION_URL="$API_URL/api/app/version?platform=agent-linux-$ARCH_SLUG¤tVersion=$INSTALLED_VERSION" VERSION_RESPONSE="" if command -v curl > /dev/null 2>&1; then VERSION_RESPONSE=$(curl -fsSL --max-time 15 "$VERSION_URL" 2>/dev/null) || VERSION_RESPONSE="" elif command -v wget > /dev/null 2>&1; then VERSION_RESPONSE=$(wget -q -T 15 -O - "$VERSION_URL" 2>/dev/null) || VERSION_RESPONSE="" fi if [ -z "$VERSION_RESPONSE" ]; then # Fail-safe, not fail-open (NFR-3): a network hiccup, timeout, or non-2xx # response means "no update" — never a fall-through to reinstall, and # never a nonzero exit that would mark the oneshot unit failed. echo "termique-agent-update: version check failed — treating as no update." exit 0 fi # Strip any whitespace after the colon too (defensive — today's JSON # responses are compact with none, but the extraction shouldn't silently # misparse "true" as "" if that ever changes). HAS_UPDATE=$(echo "$VERSION_RESPONSE" | grep -o '"hasUpdate"[[:space:]]*:[[:space:]]*[a-zA-Z]*' | head -n 1 | sed 's/.*://; s/[[:space:]]//g') FORCE_UPDATE=$(echo "$VERSION_RESPONSE" | grep -o '"forceUpdate"[[:space:]]*:[[:space:]]*[a-zA-Z]*' | head -n 1 | sed 's/.*://; s/[[:space:]]//g') if [ "$HAS_UPDATE" != "true" ] && [ "$FORCE_UPDATE" != "true" ]; then echo "termique-agent-update: already on latest version ($INSTALLED_VERSION) — nothing to do." exit 0 fi echo "termique-agent-update: update available (installed $INSTALLED_VERSION) — proceeding." # Backup current binary before overwrite (FR-4.1). Abort rather than update # without a rollback safety net. if ! cp -f "$INSTALL_PATH" "$BACKUP_PATH"; then echo "termique-agent-update: failed to back up $INSTALL_PATH — aborting." >&2 exit 1 fi # Reinstall (FR-3.3). Plain (non-exec) invocation on purpose — exec would # replace this script's process image and skip the health check + rollback # below, silently turning them into dead code. Guarded with an if-condition # (not a bare statement) so a nonzero exit here — e.g. a post-swap # systemd/dbus hiccup writing the unit files — does NOT trigger this # script's own errexit and skip the health check below; the binary may # already be swapped by the time such a failure happens, so the health # check (and its rollback) must still run regardless of this step's exit # status. if ! curl -fsSL https://get.termique.app | sh; then echo "termique-agent-update: reinstall step reported a failure — continuing to health check." >&2 fi # Health check + rollback (FR-4.2, FR-4.3, NFR-4). # # The wait must exceed the agent's collection interval. metrics.Collect calls # cpu.Percent(interval, false), which blocks for the whole interval before # returning, so the first "metrics sent" line cannot appear until roughly # interval seconds after start. This used to be a flat 15s against a 30s # default, so the check failed on every run and every update — healthy or not # — was rolled back. The agent could therefore never actually receive an # update, including a security fix. AGENT_INTERVAL=$(grep -Eo '^[[:space:]]*interval[[:space:]]*=[[:space:]]*[0-9]+' "$CONFIG_FILE" 2>/dev/null | grep -Eo '[0-9]+$' | head -1) case "$AGENT_INTERVAL" in ''|*[!0-9]*) AGENT_INTERVAL=30 ;; esac HEALTH_WAIT_SECONDS=$((AGENT_INTERVAL + 20)) # Don't let a pathological interval hang the timer for hours. if [ "$HEALTH_WAIT_SECONDS" -gt 300 ]; then HEALTH_WAIT_SECONDS=300; fi CHECK_SINCE=$(date '+%Y-%m-%d %H:%M:%S') sleep "$HEALTH_WAIT_SECONDS" HEALTHY=1 if command -v systemctl > /dev/null 2>&1; then if ! systemctl is-active --quiet termique-agent 2>/dev/null; then HEALTHY=0 fi else HEALTHY=0 fi if [ "$HEALTHY" = "1" ] && command -v journalctl > /dev/null 2>&1; then if ! journalctl -u termique-agent --since "$CHECK_SINCE" 2>/dev/null | grep -q "metrics sent"; then HEALTHY=0 fi fi if [ "$HEALTHY" = "0" ]; then echo "termique-agent-update: health check failed — rolling back to previous binary." >&2 if [ -x "$BACKUP_PATH" ]; then cp -f "$BACKUP_PATH" "$INSTALL_PATH" if command -v systemctl > /dev/null 2>&1; then systemctl restart termique-agent 2>/dev/null || true fi echo "termique-agent-update: rolled back to previous binary." >&2 else echo "termique-agent-update: no backup binary available — cannot roll back." >&2 fi else echo "termique-agent-update: update healthy, no rollback needed." fi UPDATER_BODY_EOF chmod 700 "${TMP_UPDATE}" # Install with an explicit owner instead of `mv`. A plain mv from $TMP_DIR # (under /tmp) to /usr/local/sbin is a rename() when both live on the same # filesystem, which keeps the original inode — and therefore the installing # user as owner. Since systemd runs this script as root on a daily timer, # that would let an unprivileged user rewrite a root-executed file. if [ "$(id -u)" = "0" ]; then install -o root -g root -m 700 "${TMP_UPDATE}" "${UPDATE_SCRIPT_PATH}" else sudo install -o root -g root -m 700 "${TMP_UPDATE}" "${UPDATE_SCRIPT_PATH}" fi rm -f "${TMP_UPDATE}" echo "Auto-update script written to ${UPDATE_SCRIPT_PATH}." fi # ── Install systemd service ─────────────────────────────────────────────────── SYSTEMD_SERVICE_PATH="/etc/systemd/system/termique-agent.service" SERVICE_CONTENT="[Unit] Description=Termique Monitoring Agent After=network.target Wants=network-online.target [Service] Type=simple User=${CURRENT_USER} ExecStart=${INSTALL_PATH} Restart=on-failure RestartSec=10 StandardOutput=journal StandardError=journal SyslogIdentifier=termique-agent Environment=HOME=${HOME} # Hardening — the agent reads /proc, /sys and statfs(/), and writes only its # own security-event tailing-offset state (StateDirectory below resolves to # /var/lib/termique-agent, writable even under ProtectSystem=strict). NoNewPrivileges=true ProtectSystem=strict ProtectHome=read-only StateDirectory=termique-agent PrivateTmp=true ProtectKernelTunables=true ProtectControlGroups=true RestrictAddressFamilies=AF_INET AF_INET6 RestrictNamespaces=true LockPersonality=true [Install] WantedBy=multi-user.target" if command -v systemctl > /dev/null 2>&1; then if [ "$(id -u)" = "0" ]; then printf '%s ' "${SERVICE_CONTENT}" > "${SYSTEMD_SERVICE_PATH}" else printf '%s ' "${SERVICE_CONTENT}" | sudo tee "${SYSTEMD_SERVICE_PATH}" > /dev/null fi sudo systemctl daemon-reload sudo systemctl enable termique-agent # Use restart (not start) so a re-install of an already-running agent # actually reloads the new binary and config — 'start' is a no-op on a # running service and would leave the old (possibly revoked) token loaded. sudo systemctl restart termique-agent echo "Termique monitoring agent installed and started." # Daily auto-update timer — fires at 03:00 with up to 30 min random spread # so servers don't all hit GitHub at the same moment. Skippable via # TERMIQUE_AUTO_UPDATE=false (FR-5.1). if [ "${TERMIQUE_AUTO_UPDATE}" = "false" ]; then echo "TERMIQUE_AUTO_UPDATE=false — auto-update timer not installed." else UPDATE_SERVICE_PATH="/etc/systemd/system/termique-agent-update.service" UPDATE_TIMER_PATH="/etc/systemd/system/termique-agent-update.timer" UPDATE_SERVICE="[Unit] Description=Termique Agent Auto-Update After=network-online.target Wants=network-online.target [Service] Type=oneshot ExecStart=${UPDATE_SCRIPT_PATH} StandardOutput=journal StandardError=journal SyslogIdentifier=termique-agent-update" UPDATE_TIMER="[Unit] Description=Termique Agent Auto-Update Timer [Timer] OnCalendar=*-*-* 03:00:00 RandomizedDelaySec=1800 Persistent=true [Install] WantedBy=timers.target" if [ "$(id -u)" = "0" ]; then printf '%s ' "${UPDATE_SERVICE}" > "${UPDATE_SERVICE_PATH}" printf '%s ' "${UPDATE_TIMER}" > "${UPDATE_TIMER_PATH}" else printf '%s ' "${UPDATE_SERVICE}" | sudo tee "${UPDATE_SERVICE_PATH}" > /dev/null printf '%s ' "${UPDATE_TIMER}" | sudo tee "${UPDATE_TIMER_PATH}" > /dev/null fi sudo systemctl daemon-reload sudo systemctl enable --now termique-agent-update.timer echo "Auto-update timer installed (daily at 03:00 ± 30 min)." fi else echo "Warning: systemctl not found. Skipping systemd service setup." >&2 echo "To start the agent manually, run: ${INSTALL_PATH}" >&2 echo "Termique agent binary and config installed successfully." >&2 echo "Termique monitoring agent installed (no systemd — start it manually)." fi