#!/usr/bin/env bash # # Ermeon agent installer. # # This is the script served by get.ermeon.com. The panel shows the owner a # single line: # # /bin/bash -c "$(curl -fsSL https://get.ermeon.com)" -- --token erm_init_... # # The form is Homebrew's and rustup's, and not for familiarity. With # `curl … | bash` the script arrives on stdin, so the installer can no longer # ask anything — reading stdin would eat its own tail. Here it arrives as an # argument, stdin stays free, and the privilege question can be asked properly. # # The run is a sequence of named steps, and each one says what it did. An # installer that prints nothing until it either finishes or fails leaves the # owner guessing which of the six things went wrong. # # Step 1 runs as the invoking user, because the answer decides what the service # will be allowed to do. Installing the service itself needs root either way, # so the script re-runs itself under sudo after that answer — one password # prompt, at the point where it is actually needed. # # What it does NOT do: install the NVIDIA driver. That ends in a reboot, and # turning "install monitoring" into "reboot the server" is not this script's # call — there is a server.update_driver command for that. # # IDEMPOTENT: running it again updates binaries and configuration without # touching credentials or the command journal. # # Normative dependency list: infrastructure/17-agent-host-requirements.md. # set -euo pipefail SERVICE_USER="ermeon" BIN_DIR="/usr/lib/ermeon" CONF_DIR="/etc/ermeon" STATE_DIR="/var/lib/ermeon-agent" UNIT_NAME="ermeon-agent.service" UNIT_PATH="/etc/systemd/system/${UNIT_NAME}" DROPIN_DIR="/etc/systemd/system/${UNIT_NAME}.d" # The agent's single public entry point: register/hello/rotate/config, # telemetry, events and the command WebSocket all resolve on it by exact path. DEFAULT_API_URL="https://agent.ermeon.com" # Binaries come from wherever this script came from. DEFAULT_DIST_URL="https://get.ermeon.com" # Diagnostic packages. Their absence NARROWS the report but never fails the # install: disk model, capacity and temperature come from sysfs without them. OPTIONAL_PACKAGES=(smartmontools nvme-cli) API_URL="${ERMEON_API_URL:-$DEFAULT_API_URL}" DIST_URL="${ERMEON_DIST_URL:-$DEFAULT_DIST_URL}" INIT_TOKEN="" MODE="native" PRIVILEGE="" SOURCE_DIR="" ESCALATED=0 TOTAL_STEPS=6 # ── output ────────────────────────────────────────────────────────────────── if [[ -t 1 ]]; then B=$'\033[1m'; DIM=$'\033[2m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m' RED=$'\033[31m'; R=$'\033[0m' else B=""; DIM=""; GREEN=""; YELLOW=""; RED=""; R="" fi step() { printf '\n %s%s/%s%s %s%s%s\n' "$DIM" "$1" "$TOTAL_STEPS" "$R" "$B" "$2" "$R"; } ok() { printf ' %s✓%s %-20s %s\n' "$GREEN" "$R" "$1" "${2:-}"; } skip() { printf ' %s•%s %-20s %s\n' "$YELLOW" "$R" "$1" "${2:-}"; } note() { printf ' %s%s%s\n' "$DIM" "$*" "$R"; } warn() { printf ' %s!%s %s\n' "$YELLOW" "$R" "$*" >&2; } die() { printf '\n %s✗%s %s\n\n' "$RED" "$R" "$*" >&2; exit 1; } usage() { cat <<'USAGE' Usage: install.sh --token [options] --token TOKEN one-time registration token from the panel (required) --privilege LEVEL full | readonly (default: asked, else readonly) --api-url URL control plane address (default https://agent.ermeon.com) --dist-url URL where to fetch binaries (default https://get.ermeon.com) --mode MODE native | emulator (default native) --from DIR offline install from a directory of binaries, no download USAGE } while [[ $# -gt 0 ]]; do case "$1" in --token) INIT_TOKEN="${2:-}"; shift 2 ;; --privilege) PRIVILEGE="${2:-}"; shift 2 ;; --api-url) API_URL="${2:-}"; shift 2 ;; --dist-url) DIST_URL="${2:-}"; shift 2 ;; --mode) MODE="${2:-}"; shift 2 ;; --from) SOURCE_DIR="${2:-}"; shift 2 ;; # Internal: set when the script re-runs itself under sudo, so the plan # is not printed twice and step 1 is not asked twice. --escalated) ESCALATED=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "unknown option: $1" ;; esac done [[ "$MODE" == "native" || "$MODE" == "emulator" ]] || die "--mode: native or emulator" # ── the plan, printed once, before anything happens ───────────────────────── if [[ $ESCALATED -eq 0 ]]; then cat </dev/null; then exec 3< /dev/tty sink="/dev/tty" else return 1 fi cat < "$sink" ${B}read-only${R} telemetry, diagnostics, GPU health, driver errors. The agent runs as an unprivileged user and cannot change anything on your GPUs. ${B}full${R} all of the above, plus control: power limit, fan policy, thermal policy, host reboot, driver install. ASK printf ' Which one? [full/read-only] (read-only): ' > "$sink" local answer="" if [[ -t 0 ]]; then read -r answer || return 1 else # The descriptor is closed on BOTH paths: nobody may be sitting at the # terminal, the read then fails, and an open fd 3 would outlive the # rest of the install. read -r answer <&3 || { exec 3<&-; return 1; } exec 3<&- fi case "${answer:-read-only}" in full) PRIVILEGE="full" ;; read-only|readonly|"") PRIVILEGE="readonly" ;; *) PRIVILEGE="readonly" ;; esac return 0 } if [[ $ESCALATED -eq 0 ]]; then step 1 "What may the agent do on this host?" if [[ -n "$PRIVILEGE" ]]; then note "chosen on the command line" elif ! ask_privilege; then # No terminal at all — ansible, cloud-init. The default is the smaller # grant, and it is taken out loud rather than in silence. PRIVILEGE="readonly" note "no terminal to ask on — using read-only" fi [[ "$PRIVILEGE" == "full" || "$PRIVILEGE" == "readonly" ]] \ || die "--privilege: full or readonly" if [[ "$PRIVILEGE" == "full" ]]; then ok "full" "the agent may control your GPUs" else ok "read-only" "the agent cannot change your GPUs" fi fi # Installing a system service needs root whichever answer was given: a system # user, a unit under /etc/systemd/system, files in /usr/lib. That is a # different question from what the agent may do afterwards, which is why it is # asked separately and only once. if [[ $EUID -ne 0 ]]; then if ! command -v sudo >/dev/null 2>&1; then die "installing a system service needs root, and sudo is not available — run this as root" fi # Not a numbered step: the remaining five run on the other side of sudo, # and numbering this one would put a 5 between step 1 and step 2. printf '\n %sSteps 2-6 install a system service%s — a system user, a unit in\n' "$B" "$R" printf ' /etc/systemd/system and files in %s.\n' "$BIN_DIR" printf ' %ssudo will ask for your password.%s\n\n' "$DIM" "$R" # The script arrived through `bash -c "$(curl …)"`, so there is no file to # re-execute — bash does not expose its own `-c` text. Fetching it again is # the honest way to hand a copy to root, from the same TLS origin. # # The token travels in argv exactly as it already did in the command the # panel handed over; this adds no exposure that was not there. exec sudo -- /bin/bash -c "$(curl -fsSL "${DIST_URL%/}")" -- \ --escalated \ --token "$INIT_TOKEN" \ --privilege "$PRIVILEGE" \ --api-url "$API_URL" \ --dist-url "$DIST_URL" \ --mode "$MODE" fi [[ "$PRIVILEGE" == "full" || "$PRIVILEGE" == "readonly" ]] || PRIVILEGE="readonly" # ── 2. host checks ────────────────────────────────────────────────────────── # # All of them run BEFORE anything is created. An install that dies halfway # leaves the host in a state nobody asked for, and untangling that is harder # than not starting. step 2 "Checking the host" command -v systemctl >/dev/null 2>&1 \ || die "no systemd on this host — the agent installs on systemd hosts only" ok "systemd" "$(systemctl --version | head -1)" command -v curl >/dev/null 2>&1 \ || die "curl is required: without it we can neither fetch the agent nor check connectivity" # The token is required on the FIRST install only: afterwards the credential # lives in the state directory, and demanding a fresh one would break # reinstalls. if [[ -z "$INIT_TOKEN" && ! -f "${STATE_DIR}/identity.json" ]]; then die "--token is required for a first install: take one from the panel, \"Add a server\"" fi if [[ "$MODE" == "native" ]]; then if [[ -e /dev/nvidiactl ]]; then driver_version="$(cat /proc/driver/nvidia/version 2>/dev/null \ | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" ok "NVIDIA driver" "${driver_version:-loaded}" # Driver and kernel must agree. We have seen a kernel update arrive # without linux-modules-nvidia-*, and the driver disappear on the next # reboot. Warning is cheaper than diagnosing it later. if ! find "/lib/modules/$(uname -r)" -name 'nvidia*.ko*' -print -quit 2>/dev/null | grep -q .; then warn "no NVIDIA modules for kernel $(uname -r) — the driver may vanish on reboot" fi else skip "NVIDIA driver" "not loaded — host metrics only, no GPU telemetry" fi fi # Reachability is checked BEFORE the credential is written: behind a closed # firewall you would otherwise get an installed agent that never checks in, and # that reads as a broken agent. # # ANY http status counts as reachable, including 404. The agent plane answers # only its own exact paths and 404s everything else — probing /healthz there # made every single install print a scary warning about a control plane that # was working fine. What we are testing is DNS, TCP and TLS; only code 000, # which curl reports when the connection itself never happened, means trouble. api_status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "${API_URL%/}/" 2>/dev/null || echo 000)" if [[ "$api_status" != "000" ]]; then ok "control plane" "${API_URL#https://} reachable" else warn "control plane ${API_URL} is not reachable — check the address and firewall" fi # ── 3. binaries ───────────────────────────────────────────────────────────── step 3 "Downloading the agent" STAGE="" cleanup() { [[ -n "$STAGE" && -d "$STAGE" ]] && rm -rf "$STAGE"; } trap cleanup EXIT if [[ -z "$SOURCE_DIR" ]]; then STAGE="$(mktemp -d)" for binary in ermeon-agent ermeon-hwd; do curl -fsSL --max-time 300 "${DIST_URL%/}/bin/${binary}" -o "${STAGE}/${binary}" \ || die "could not download ${binary} from ${DIST_URL}" ok "$binary" "$(du -h "${STAGE}/${binary}" | cut -f1)" done # Checksums are mandatory. A binary pulled over the network and then run # with privileges is exactly the place where a silent "it probably arrived # intact" costs the most. curl -fsSL --max-time 60 "${DIST_URL%/}/bin/SHA256SUMS" -o "${STAGE}/SHA256SUMS" \ || die "could not download SHA256SUMS: nothing left to verify the binaries with" ( cd "$STAGE" && sha256sum --check --status --ignore-missing SHA256SUMS ) \ || die "checksums did not match — install aborted" ok "checksums" "verified" SOURCE_DIR="$STAGE" else for binary in ermeon-agent ermeon-hwd; do [[ -f "${SOURCE_DIR}/${binary}" ]] || die "${SOURCE_DIR}/${binary} not found" done ok "local copy" "$SOURCE_DIR" fi # ── 4. helper packages ────────────────────────────────────────────────────── step 4 "Installing helper packages" package_manager() { command -v apt-get >/dev/null 2>&1 && { echo apt; return; } command -v dnf >/dev/null 2>&1 && { echo dnf; return; } command -v yum >/dev/null 2>&1 && { echo yum; return; } } install_optional_packages() { local manager; manager="$(package_manager)" [[ -n "$manager" ]] || return 1 case "$manager" in apt) DEBIAN_FRONTEND=noninteractive apt-get install -y -q "${OPTIONAL_PACKAGES[@]}" ;; dnf) dnf install -y -q "${OPTIONAL_PACKAGES[@]}" ;; yum) yum install -y -q "${OPTIONAL_PACKAGES[@]}" ;; esac } # A package manager failure is a narrower report, not a failed install: disk # model, capacity and temperature come from sysfs without these packages. if install_optional_packages >/dev/null 2>&1; then ok "${OPTIONAL_PACKAGES[*]}" "for SMART attributes" else skip "${OPTIONAL_PACKAGES[*]}" "not installed — disk diagnostics without SMART" fi # ── 5. the service ────────────────────────────────────────────────────────── step 5 "Writing the systemd service" id -u "$SERVICE_USER" >/dev/null 2>&1 \ || useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" ok "user" "$SERVICE_USER" # Membership in systemd-journal is the cheapest line in this installer and one # of the most valuable: journalctl hands kernel messages to a group member # WITHOUT CAP_SYSLOG, whereas /dev/kmsg at mode 644 is closed by dmesg_restrict. # This single line brings XID collection and log diagnostics back in read-only # mode. if getent group systemd-journal >/dev/null 2>&1; then usermod -aG systemd-journal "$SERVICE_USER" ok "journal access" "GPU driver errors readable without root" else skip "journal access" "no systemd-journal group on this host" fi install -d -o root -m 0755 "$BIN_DIR" install -d -o root -m 0750 "$CONF_DIR" install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0700 "$STATE_DIR" install -o root -m 0755 "${SOURCE_DIR}/ermeon-agent" "${BIN_DIR}/ermeon-agent" install -o root -m 0755 "${SOURCE_DIR}/ermeon-hwd" "${BIN_DIR}/ermeon-hwd" ok "binaries" "$BIN_DIR" install -o root -m 0640 /dev/null "${CONF_DIR}/agent.env" cat > "${CONF_DIR}/agent.env" < "${STATE_DIR}/init-token" chmod 0400 "${STATE_DIR}/init-token" ok "registration token" "stored root-only, deleted after first check-in" fi # The unit is embedded rather than fetched as a separate file: two sources of # truth for one artifact drift apart silently. # # What is deliberately NOT in the sandboxing, and why: # NoNewPrivileges=true would break full mode — the helper must keep its rights; # ProtectSystem=strict the agent writes to /var/lib/ermeon-agent; # PrivateDevices=true would hide /dev/nvidia*, i.e. all of the hardware; # ProtectKernelLogs=true would close the kernel journal that driver errors come from. # # StartLimit* live in [Unit], not [Service]: systemd silently ignores them in # [Service] — confirmed with `systemd-analyze verify`. cat > "$UNIT_PATH" <<'UNIT' [Unit] Description=Ermeon agent — GPU host telemetry, diagnostics and commands Documentation=https://ermeon.com/docs/agent After=network.target Wants=network.target StartLimitIntervalSec=300 StartLimitBurst=10 [Service] Type=exec EnvironmentFile=/etc/ermeon/agent.env ExecStart=/usr/lib/ermeon/ermeon-agent Restart=always RestartSec=5s KillMode=mixed TimeoutStopSec=30s NoNewPrivileges=false ProtectHome=true ProtectHostname=true ProtectKernelLogs=false PrivateTmp=true RestrictSUIDSGID=true RemoveIPC=true SyslogIdentifier=ermeon-agent [Install] WantedBy=multi-user.target UNIT chmod 0644 "$UNIT_PATH" # The unit starts as root: in full mode the agent must fork its privileged # helper as the very first thing the process does, and only then drop # privileges irreversibly. In read-only it never drops anything and must not # run as root — hence the drop-in. The difference between the modes is a # separate file you can read with `systemctl cat`, not something hidden in a # generator. rm -rf "$DROPIN_DIR" if [[ "$PRIVILEGE" == "readonly" ]]; then install -d -o root -m 0755 "$DROPIN_DIR" cat > "${DROPIN_DIR}/10-readonly.conf" <