#!/usr/bin/env bash # ============================================================================ # Monitoring agent installer for a Linux host (Debian/Ubuntu). # # Installs Grafana Alloy as the only agent. It collects metrics (node_exporter # is built into Alloy) and logs (journal + /var/log), and PUSHES both over # HTTPS to the monitoring server. Nothing listens on this host; it only needs # outbound 443 — no VPN, no open ports. # # 1. On the monitoring server: ./host.sh add web01 # prints a one-time enrollment code and this exact command: # 2. On this host: # curl -fsSL https:///install-linux.sh | sudo ENROLL= bash # # The served copy of this script already knows INGEST_URL. The host's name # comes from the enrollment (the name given to host.sh add). # # Re-run WITHOUT ENROLL to upgrade or repair: the existing token is kept. # Re-run WITH a new code to re-enroll (rotates the token). # A host still running the old pull agent (node_exporter on :9100) has it # removed — Alloy replaces it. # ============================================================================ set -euo pipefail # Everything runs inside main(), which bash parses completely before running # it — so with `curl ... | bash`, nothing below (apt, dpkg, ...) can read the # rest of the script from stdin. main() { export DEBIAN_FRONTEND=noninteractive # Pinned version. downloads/fetch.sh on the server reads it to build the # download site, so changing it here changes what the server serves. ALLOY_VERSION="1.5.1" # keep in sync with the stack's pinned tag # SCRIPT_DIR is empty when the script is piped in (curl ... | bash): then the # config comes from the server and no stray .env in the cwd is loaded. SCRIPT_DIR="" if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fi [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/.env" ]] && set -a && . "$SCRIPT_DIR/.env" && set +a # The served copy of this script fills in this default. INGEST_URL="${INGEST_URL:-}" # @default:INGEST_URL INGEST_URL="${INGEST_URL%/}" ENROLL="${ENROLL:-}" TOKEN_FILE=/etc/alloy/monitor-token log() { printf '\033[1;32m[install]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Run as root (sudo)." [[ -n "$INGEST_URL" ]] || die "Set INGEST_URL (https://), or use the copy served by the monitoring server." [[ "$INGEST_URL" == https://* ]] || die "INGEST_URL must be https:// (got $INGEST_URL)." command -v curl >/dev/null || die "curl is required." # The monitoring server monitors itself already (and runs Prometheus in Docker). if command -v docker >/dev/null && docker ps --format '{{.Names}}' 2>/dev/null | grep -qx prometheus; then die "This looks like the monitoring server itself — it already monitors itself." fi if [[ -z "$ENROLL" && ! -s "$TOKEN_FILE" ]]; then die "No enrollment code. On the monitoring server run: ./host.sh add then run the command it prints (… | sudo ENROLL= bash)." fi TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT chmod 755 "$TMP" # lets apt's _apt user read a local .deb without warnings # get : download from the server, verify against its SHA256SUMS. get() { local path="$1" dest="$2" want curl -fsSL "$INGEST_URL/$path" -o "$dest" || die "download failed: $INGEST_URL/$path" [[ -f "$TMP/SHA256SUMS" ]] || curl -fsSL "$INGEST_URL/SHA256SUMS" -o "$TMP/SHA256SUMS" \ || die "could not fetch $INGEST_URL/SHA256SUMS" want="$(awk -v f="$path" '$2==f {print $1}' "$TMP/SHA256SUMS")" [[ -n "$want" ]] || die "$path is not listed in the server's SHA256SUMS" echo "$want $dest" | sha256sum -c --status || die "CHECKSUM MISMATCH for $path" } case "$(uname -m)" in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; armv7l) ARCH=armv7 ;; *) die "Unsupported arch: $(uname -m)" ;; esac # ============================================================================ # 1) Enroll first, so a bad code fails before anything is installed. # ============================================================================ mkdir -p /etc/alloy if [[ -n "$ENROLL" ]]; then log "Enrolling with ${INGEST_URL} ..." resp="$(curl -sS -m 30 -w '\n%{http_code}' --data-urlencode "code=${ENROLL}" \ -d kind=linux "$INGEST_URL/enroll" 2>&1 || true)" code="${resp##*$'\n'}"; body="${resp%$'\n'*}" [[ "$code" == 200 ]] || die "enrollment failed (${code}): ${body}" NODE_NAME="$(sed -n 's/^name=//p' <<<"$body")" token="$(sed -n 's/^token=//p' <<<"$body")" [[ -n "$NODE_NAME" && -n "$token" ]] || die "unexpected enrollment response" ( umask 077; printf '%s\n' "$token" > "$TOKEN_FILE" ) log "Enrolled as '${NODE_NAME}'." else NODE_NAME="$(sed -n 's/^NODE_NAME="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' /etc/default/alloy 2>/dev/null | head -n1)" [[ -n "$NODE_NAME" ]] || die "Already have a token but no NODE_NAME in /etc/default/alloy — re-enroll: ./host.sh add on the server." log "Keeping the existing enrollment ('${NODE_NAME}')." fi # ============================================================================ # 2) Grafana Alloy # ============================================================================ if [[ "$ARCH" == amd64 || "$ARCH" == arm64 ]]; then DEB="alloy-${ALLOY_VERSION}-1.${ARCH}.deb" log "Installing Alloy ${ALLOY_VERSION} (${ARCH}) from ${INGEST_URL} ..." get "bin/${DEB}" "$TMP/${DEB}" apt-get install -y -qq --allow-downgrades "$TMP/${DEB}" >/dev/null elif ! command -v alloy &>/dev/null; then # armv7: no upstream .deb, use Grafana's APT repository. log "Adding Grafana APT repo and installing Alloy..." apt-get update -qq apt-get install -y -qq apt-transport-https ca-certificates gpg >/dev/null mkdir -p /etc/apt/keyrings curl -fsSL https://apt.grafana.com/gpg.key | gpg --dearmor > /etc/apt/keyrings/grafana.gpg echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" \ > /etc/apt/sources.list.d/grafana.list apt-get update -qq apt-get install -y -qq alloy >/dev/null fi log "Writing Alloy config ..." if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/config.alloy" ]]; then install -m 0644 "$SCRIPT_DIR/config.alloy" /etc/alloy/config.alloy else get "linux/config.alloy" "$TMP/config.alloy" install -m 0644 "$TMP/config.alloy" /etc/alloy/config.alloy fi # Token readable by the alloy service user only. chown root:alloy "$TOKEN_FILE"; chmod 0640 "$TOKEN_FILE" # Environment for the alloy unit (EnvironmentFile), read by sys.env(...). cat > /etc/default/alloy </dev/null || true systemctl enable alloy >/dev/null 2>&1 systemctl restart alloy # ============================================================================ # 3) Remove the old pull agent (node_exporter on :9100), if this installer # put it there earlier. Alloy now collects the same metrics. # ============================================================================ if [[ -f /etc/systemd/system/node_exporter.service ]]; then log "Removing the old node_exporter service (replaced by Alloy; closes :9100) ..." systemctl disable --now node_exporter >/dev/null 2>&1 || true rm -f /etc/systemd/system/node_exporter.service /usr/local/bin/node_exporter systemctl daemon-reload userdel node_exporter 2>/dev/null || true fi # ============================================================================ # 4) Confirm the server is receiving data. # ============================================================================ log "Waiting for the first push to reach the server ..." ok="" for _ in $(seq 1 30); do st="$(curl -fsS -m 10 -H "Authorization: Bearer $(cat "$TOKEN_FILE")" "$INGEST_URL/status" 2>/dev/null || true)" age="$(sed -n 's/^last_push_age=//p' <<<"$st")" if [[ -n "$age" && "$age" -ge 0 && "$age" -le 90 ]]; then ok=1; break; fi sleep 3 done echo if [[ -n "$ok" ]]; then log "Done. The server is receiving data from '${NODE_NAME}' — it appears in Grafana within a minute." else warn "Alloy is installed, but the server has not received a push from '${NODE_NAME}' yet." warn "Check: systemctl status alloy and journalctl -u alloy -n 50" warn "(this host needs outbound HTTPS to ${INGEST_URL})" fi } main "$@"