#!/bin/sh # NodeMesh Agent Installer # Usage: curl -sSL https://install.nodemesh.app | sudo sh -s -- --token YOUR_TOKEN # or: sh install.sh --token YOUR_TOKEN # # Non-root is the DEFAULT, not a mandate — pass --as-root (or set # NODEMESH_SERVICE_USER=root) to explicitly opt into running the agent, and # every game it launches, as root. See "OPTING INTO ROOT" below. # # This script: # 1. Detects the host OS/arch # 2. Resolves the EFFECTIVE on-host config (unit's merged User=, via # `systemctl show` — sees drop-ins; existing agent.env's data dir) and # classifies the host as root (legacy OR deliberately opted-in) or # fresh/already-non-root from THAT, never from the immortal legacy-binary # path alone (see below) # 3. On a root host (legacy or deliberate), skips only the privilege- # CONVERSION steps (service-user creation, ownership chowns, binary # relocation, User=/Group= in the unit) — everything else (token/config # refresh, binary refresh, service restart) still runs, because # re-running this installer with a fresh --token is a documented # recovery route for a stranded agent and must keep working # 4. Creates/adopts the unprivileged service user (games refuse to run as # root) — fresh or already-non-root hosts only # 5. Downloads the nodemesh-agent binary # 6. Verifies the SHA-256 checksum (aborts on mismatch; graceful warning if manifest unavailable) # 7. Best-effort preinstalls SteamCMD's 32-bit deps — skipped on a root # host, where this may be the exact re-run rescuing an unhealthy host # 8. Installs the binary under a service-user-owned directory (self-update # needs write access to its own directory, not just the binary) # 9. Creates the config at /etc/nodemesh/agent.env and, on a fresh/adopted # host, chowns the data tree to the service user (after PROBING that the # filesystem can represent that ownership — see below) # 10. Installs and starts a systemd service — non-root on a fresh/adopted # host, unchanged (root) on a legacy or deliberate-root host # # OPTING INTO ROOT: --as-root (or NODEMESH_SERVICE_USER=root) is a # first-class, explicit choice — not a fallback and not the same as an old # ("legacy") root host. It skips service-user creation and the ownership # chown walk and writes a unit with no User= (root), same as install.sh has # always done. The choice is recorded (a marker under CONFIG_DIR) and is # DURABLE: a later re-run (e.g. to rotate a pairing token) keeps the host # root without being asked again, and — symmetrically — a non-root host is # never silently flipped to root, nor a deliberately-root host silently # flipped to non-root. Converting between the two after the fact needs the # manual recipe this script prints (or, later, Slice 4's real migration) — # never a plain re-run. # # Scope note: this script is Slice 2 of docs/superpowers/plans/2026-08-21- # agent-nonroot.md — fresh installs get the non-root layout. Converting an # EXISTING root install's ownership/privilege model is Slice 4 (a # root-ordered, marker-resumable agent op with backend stop/restart # orchestration, gated on a founder hardware pass) — see # docs/superpowers/specs/2026-08-21-agent-nonroot-design.md §6. This script # must never perform that conversion itself, even partially — but it must # keep doing the ordinary maintenance a re-run has always done (refresh the # token/config, refresh the binary, restart the service), because that is # the same re-run operators use to recover a host stranded by an expired # pairing token. Until Slice 3 ships a real "Secure this host" panel action, # a legacy host's operator is pointed at the manual chown recipe # internal/web/handlers/startup_hint.go already gives them — the plan is # explicit that Slice 3 must not advertise a button that does not exist yet, # and this script must not either. # # Classification is done from the EFFECTIVE systemd config # (`systemctl show -p User --value nodemesh-agent`), not from grepping the # base unit file or checking whether the legacy binary still exists at # LEGACY_INSTALL_DIR. Two real hosts break under either of those simpler # checks: (a) a Slice-4-migrated host writes its User= into a systemd # drop-in, never the base unit (design §6.3 step 6, so an operator's manual # edits survive) — grepping only the base unit misses it; (b) the legacy # binary is NEVER deleted, by design (§6.3a — the base unit's ExecStart may # still need it, and it is what a Slice 4 rollback lands on), so its mere # presence proves nothing about whether the unit currently runs non-root. # An operator who follows this very script's own manual recipe (added # User=nodemesh to the base unit by hand) must be adopted, not silently # reconverted to root on their next re-run — design §6.3 step 6, "adopt, # don't fight" (R5). set -eu BACKEND_ADDR="${NODEMESH_BACKEND_ADDR:-api.nodemesh.app:443}" TLS_ENABLED="${NODEMESH_TLS_ENABLED:-true}" DOWNLOAD_BASE="${NODEMESH_DOWNLOAD_BASE:-https://nodemesh.app/releases}" NODEMESH_REPORT_URL="${NODEMESH_REPORT_URL:-https://nodemesh.app/install/report}" NODEMESH_STATUS_URL="${NODEMESH_STATUS_URL:-https://nodemesh.app/install/status}" # Bounded wait for the control plane's pairing verdict. The agent retries # Register every 5s (cmd/agent/main.go), so a rejection is recorded within # ~10s of service start; 120s is ~24 attempts' worth of headroom for a slow # start, DNS, or a busy control plane. See pair_wait below for why the wait # exists at all. PAIR_TIMEOUT_SEC=120 PAIR_POLL_SEC=5 # report_stage STAGE [CODE] [DETAIL] # # Best-effort, always. A report that fails, hangs, or errors must never # change this script's exit code or stop an install that would otherwise # have worked — our telemetry is not allowed to become a new way for # installs to fail. No output on the normal path (nothing that could # confuse a user reading the terminal); the selftest hatch is the only # exception, and only to prove this function actually ran. report_stage() { [ -n "${TOKEN:-}" ] || return 0 _stage="$1" _code="${2:-}" # Sanitize BOTH the token and the free-text detail identically — a token # containing a `"` or `\` would otherwise produce malformed JSON the # server cannot decode, silently losing every report for that install # (exactly the blindness this feature exists to remove). Control # characters (a raw newline/tab, e.g. from multi-line `systemctl` stderr) # are just as fatal to JSON validity, so strip those too — one shared # pipeline for token and detail so they can never diverge. _token="$(printf '%s' "$TOKEN" | tr -d '"\\' | tr -d '\000-\037')" _detail="$(printf '%s' "${3:-}" | tr -d '"\\' | tr -d '\000-\037' | cut -c1-512)" _body="$(printf '{"token":"%s","stage":"%s","code":"%s","detail":"%s","os":"linux","arch":"%s"}' \ "$_token" "$_stage" "$_code" "$_detail" "${ARCH:-}")" if [ "${NODEMESH_INSTALL_SH_SELFTEST:-}" = "1" ]; then echo "REPORT: $_body" return 0 fi if command -v curl >/dev/null 2>&1; then curl -sS -m 5 -o /dev/null -X POST -H 'content-type: application/json' \ --data "$_body" "$NODEMESH_REPORT_URL" >/dev/null 2>&1 || true elif command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null --header='content-type: application/json' \ --post-data="$_body" "$NODEMESH_REPORT_URL" >/dev/null 2>&1 || true fi return 0 } # pairing_outcome — ask the control plane whether TOKEN actually paired. # # Echoes exactly one of: paired | rejected | pending | unreachable. # "unreachable" is OUR network failing, not a verdict, and is kept distinct # from "pending" so the timeout message can name the right cause. # # Unlike report_stage this is NOT fire-and-forget — its answer is the whole # point. It still cannot fail the script by itself: an unparseable or missing # answer degrades to "pending", and only the bounded wait around it decides. pairing_outcome() { [ -n "${TOKEN:-}" ] || { echo pending; return 0; } _ptoken="$(printf '%s' "$TOKEN" | tr -d '"\\' | tr -d '\000-\037')" _pbody="$(printf '{"token":"%s"}' "$_ptoken")" if command -v curl >/dev/null 2>&1; then _presp="$(curl -sS -m 10 -X POST -H 'content-type: application/json' \ --data "$_pbody" "$NODEMESH_STATUS_URL" 2>/dev/null)" || { echo unreachable; return 0; } elif command -v wget >/dev/null 2>&1; then _presp="$(wget -q -T 10 -O - --header='content-type: application/json' \ --post-data="$_pbody" "$NODEMESH_STATUS_URL" 2>/dev/null)" || { echo unreachable; return 0; } else # No HTTP client at all. The download above cannot have succeeded without # one, so this is unreachable in practice — but guessing "paired" here # would be the exact false success this code removes. echo unreachable return 0 fi case "$_presp" in *'"state":"paired"'*) echo paired ;; *'"state":"rejected"'*) echo rejected ;; *) echo pending ;; esac } # The agent binary lives under a service-user-owned directory rather than the # shared /usr/local/bin: self-update (pkg/selfupdate) writes a same-directory # ".new" download and a ".bak" backup, then renames over the running binary — # that needs the *directory* to be writable by the service user, and making # /usr/local/bin writable by a non-root service account would be its own # security smell. LEGACY_INSTALL_DIR is the old (pre-nonroot) location — it # is NEVER removed by this script (design §6.3a) and, on a detected legacy # host, stays the binary's install location (see the privilege-conversion # gating below). The three *_OVERRIDE vars exist solely so a real `sh` test # harness can point classification at a disposable temp directory instead of # real system paths — never set outside internal/web/handlers/install_path_test.go. INSTALL_DIR="/opt/nodemesh/bin" LEGACY_INSTALL_DIR="${NODEMESH_LEGACY_INSTALL_DIR_OVERRIDE:-/usr/local/bin}" CONFIG_DIR="${NODEMESH_CONFIG_DIR_OVERRIDE:-/etc/nodemesh}" DATA_DIR="/opt/nodemesh/data/instances" DATA_DIR_EXPLICIT=false SERVICE_FILE="${NODEMESH_SERVICE_FILE_OVERRIDE:-/etc/systemd/system/nodemesh-agent.service}" # The unprivileged system user the agent — and every game process it spawns — # runs as, on a fresh (or already-converted) host. Several games (Satisfactory # among them) abort instantly with "Refusing to run with the root privileges" # when launched as root, which is what happened by default: the unit # previously carried no User=. This is only the DEFAULT — if the effective # unit already names a different non-root user (an operator's own manual fix, # or a Slice 4 migration adopting a pre-existing User=X), that user is # adopted instead (design §6.3 step 6, R5); see the classification below. SERVICE_USER_DEFAULT="${NODEMESH_SERVICE_USER:-nodemesh}" TOKEN="" # AS_ROOT_REQUESTED is the explicit, first-class opt-in for a root install # (see "OPTING INTO ROOT" above) — set by --as-root, or by setting # NODEMESH_SERVICE_USER=root itself (which used to just happen to "work" by # accident; it is now an explicitly supported value, not merely tolerated). # AS_ROOT_MARKER is where that choice is durably recorded so a later re-run # (e.g. to rotate a pairing token) remembers it without being asked again — # see the classification block below. AS_ROOT_REQUESTED=false AS_ROOT_MARKER="${CONFIG_DIR}/.nodemesh-as-root" # ── parse flags ─────────────────────────────────────────────────────────────── while [ $# -gt 0 ]; do case "$1" in --token) TOKEN="$2" shift 2 ;; --backend) BACKEND_ADDR="$2" shift 2 ;; --data-dir) DATA_DIR="$2" DATA_DIR_EXPLICIT=true shift 2 ;; --no-tls) # For self-hosted backends without a TLS endpoint (local dev). TLS_ENABLED="false" shift ;; --as-root) # Explicit opt-in: run the agent (and every game it launches) as root. # Non-root is the default; this is a deliberate, informed choice, not # a fallback — see "OPTING INTO ROOT" at the top of this file. AS_ROOT_REQUESTED=true shift ;; *) echo "Unknown flag: $1" >&2 exit 1 ;; esac done if [ "$SERVICE_USER_DEFAULT" = "root" ]; then # NODEMESH_SERVICE_USER=root is the env-var equivalent of --as-root. AS_ROOT_REQUESTED=true fi if [ -z "$TOKEN" ]; then printf "NodeMesh Agent Installer\n\nPaste your pairing token: " read -r TOKEN fi if [ -z "$TOKEN" ]; then echo "Error: pairing token is required." >&2 exit 1 fi # ── root check ──────────────────────────────────────────────────────────────── # The script writes to /opt/nodemesh and /etc/systemd/system — these require # root. NODEMESH_INSTALL_SH_SELFTEST is a test-only escape hatch (see # internal/web/handlers/install_path_test.go) that lets a real `sh` exercise # classification without root/network/filesystem mutation — the script exits # right after printing its classification, before touching anything, when # that variable is set. `curl | sh` never sets it. Placed after the token is # known (rather than first, as before) so report_stage — best-effort, and # never able to change this script's exit code — has a token to attach the # report to. if [ "$(id -u)" -ne 0 ] && [ "${NODEMESH_INSTALL_SH_SELFTEST:-}" != "1" ]; then report_stage preflight preflight.not_admin echo "Error: this script must be run as root." >&2 echo "" >&2 echo "Re-run with sudo." >&2 exit 1 fi # ── detect platform ─────────────────────────────────────────────────────────── OS="$(uname -s | tr '[:upper:]' '[:lower:]')" ARCH="$(uname -m)" case "$ARCH" in x86_64) ARCH="amd64" ;; aarch64) ARCH="arm64" ;; armv7l) ARCH="arm" ;; *) report_stage preflight preflight.unsupported_arch "$ARCH" echo "Unsupported architecture: $ARCH" >&2 exit 1 ;; esac if [ "$OS" != "linux" ]; then # Reused deliberately: there is no OS-specific error code (and none is # planned — it would need seven locales of translation for a case that # barely occurs), so an OS mismatch is reported under the architecture code. report_stage preflight preflight.unsupported_arch "$OS" echo "Only Linux is supported by the NodeMesh agent." >&2 exit 1 fi BINARY="nodemesh-agent-linux-${ARCH}" DOWNLOAD_URL="${DOWNLOAD_BASE}/${BINARY}" SUMS_URL="${DOWNLOAD_BASE}/../SHA256SUMS" # ── check for systemd ───────────────────────────────────────────────────────── if ! command -v systemctl >/dev/null 2>&1; then report_stage preflight preflight.no_systemd echo "Error: systemd is required to install the NodeMesh agent service." >&2 exit 1 fi report_stage preflight # ── resolve the unit's EFFECTIVE configuration ───────────────────────────────── # `systemctl show` returns the MERGED result of the base unit and any # drop-in (…/nodemesh-agent.service.d/*.conf) — see the file header for why # that (not a grep of the base unit, not the legacy binary's presence) is # what classification must be based on. EFFECTIVE_USER="" if [ -f "$SERVICE_FILE" ]; then EFFECTIVE_USER="$(systemctl show -p User --value nodemesh-agent 2>/dev/null || true)" fi is_legacy_root_install() { # No unit at all -> nothing to adopt or convert; a plain fresh install. if [ ! -f "$SERVICE_FILE" ]; then return 1 fi # A non-empty, non-root effective User= (from the base unit OR a drop-in) # means this host is already running non-root — adopt it, never reconvert. if [ -n "$EFFECTIVE_USER" ] && [ "$EFFECTIVE_USER" != "root" ]; then return 1 fi return 0 } # IS_LEGACY_ROOT_INSTALL means "skip privilege conversion this run" — it # covers BOTH a genuinely old (legacy) root host AND a host whose operator # explicitly chose root (--as-root / NODEMESH_SERVICE_USER=root). The two are # technically identical (same skip-conversion steps, same root-owned unit) # but are messaged differently — DELIBERATE_ROOT distinguishes them. # # --as-root this run always wins (a first-class, explicit ask). Otherwise, # fall through to is_legacy_root_install(): a host whose EFFECTIVE User= is # already a genuine non-root account is adopted (IS_LEGACY_ROOT_INSTALL # stays false) regardless of any stale AS_ROOT_MARKER — an operator's actual, # observed fix always wins over a marker recording an earlier intent. Only # once we're in the root branch do we check the marker, to tell a # deliberately-root host (durable — a plain re-run must not silently "fix" # it, matching the founder's non-root-is-default-not-mandate decision) from # an untouched legacy one. IS_LEGACY_ROOT_INSTALL=false DELIBERATE_ROOT=false if [ "$AS_ROOT_REQUESTED" = "true" ]; then IS_LEGACY_ROOT_INSTALL=true DELIBERATE_ROOT=true elif is_legacy_root_install; then IS_LEGACY_ROOT_INSTALL=true if [ -f "$AS_ROOT_MARKER" ]; then DELIBERATE_ROOT=true fi fi # A root host's (legacy or deliberate) runtimes/instances already live # wherever its root-owned agent resolved them. Only honor THIS run's # --data-dir when it was explicitly passed; otherwise preserve the existing # agent.env's NODEMESH_DATA_DIR so a plain re-run (e.g. to rotate an expired # pairing token) can never silently repoint — and orphan — a custom data # directory. if [ "$IS_LEGACY_ROOT_INSTALL" = "true" ] && [ "$DATA_DIR_EXPLICIT" != "true" ] && [ -f "${CONFIG_DIR}/agent.env" ]; then EFFECTIVE_DATA_DIR="$(grep '^NODEMESH_DATA_DIR=' "${CONFIG_DIR}/agent.env" 2>/dev/null | tail -n1 | cut -d= -f2-)" if [ -n "$EFFECTIVE_DATA_DIR" ]; then DATA_DIR="$EFFECTIVE_DATA_DIR" fi fi # DATA_ROOT is the *resolved* data root, derived from $DATA_DIR rather than a # hardcoded "/opt/nodemesh/data" literal — a host may keep its data on a # custom mount, and every chown/mkdir below must follow that choice (design # §2.1, plan Task 5 Step 1a). NODEMESH_RUNTIMES_DIR is derived the same way, # as a sibling of the resolved data root — this is what fixes the # /usr/local/data/runtimes exe-relative surprise for fresh hosts (design # §2.1). It is only written into agent.env on a fresh/adopted host (below) — # a root host's runtimes already live wherever its root-owned agent resolved # them, and silently redirecting that out from under a running host would # itself be a half-conversion. DATA_ROOT="$(dirname "$DATA_DIR")" RUNTIMES_DIR="${DATA_ROOT}/runtimes" if [ "$IS_LEGACY_ROOT_INSTALL" = "true" ]; then if [ "$DELIBERATE_ROOT" = "true" ]; then echo "Running the NodeMesh agent as root — by your explicit choice" >&2 echo "(--as-root / NODEMESH_SERVICE_USER=root)." >&2 echo "" >&2 echo "This means the agent AND every game process it launches run with full" >&2 echo "root privileges on this host. Some games (Satisfactory among them)" >&2 echo "refuse to start when launched as root and will not run here." >&2 echo "" >&2 echo "This choice is remembered: re-running this installer (e.g. to rotate a" >&2 echo "pairing token) keeps this host root without asking again — it will" >&2 echo "never be silently switched to the non-root layout." >&2 echo "" >&2 echo "To switch to the recommended non-root layout, secure this host by hand:" >&2 echo " 1. sudo useradd --system --home-dir /opt/nodemesh/home --shell /usr/sbin/nologin nodemesh" >&2 echo " 2. sudo chown -R nodemesh:nodemesh /opt/nodemesh/data" >&2 echo " 3. Add 'User=nodemesh' and 'Group=nodemesh' under [Service] in" >&2 echo " /etc/systemd/system/nodemesh-agent.service" >&2 echo " 4. sudo rm -f ${AS_ROOT_MARKER}" >&2 echo " 5. sudo systemctl daemon-reload && sudo systemctl restart nodemesh-agent" >&2 echo "" >&2 else echo "This host predates non-root installs — the agent (and every game it" >&2 echo "launches) is still running as root. This installer will refresh your" >&2 echo "token/config and restart the agent, but will NOT convert this host to" >&2 echo "run non-root: that requires stopping every running game server first," >&2 echo "with safety checks this script does not perform." >&2 echo "" >&2 echo "To secure this host by hand:" >&2 echo " 1. sudo useradd --system --home-dir /opt/nodemesh/home --shell /usr/sbin/nologin nodemesh" >&2 echo " 2. sudo chown -R nodemesh:nodemesh /opt/nodemesh/data" >&2 echo " 3. Add 'User=nodemesh' and 'Group=nodemesh' under [Service] in" >&2 echo " /etc/systemd/system/nodemesh-agent.service" >&2 echo " 4. sudo systemctl daemon-reload && sudo systemctl restart nodemesh-agent" >&2 echo "" >&2 fi fi # ── resolve the service user and binary directory for THIS run ──────────────── # Legacy: unchanged in place — root, at the legacy binary path. # Fresh/adopted: adopt an already-non-root EFFECTIVE_USER when the account # genuinely exists (design §6.3 step 6, R5); otherwise fall back to the # default. AGENT_BIN_DIR is the service-user-owned INSTALL_DIR. if [ "$IS_LEGACY_ROOT_INSTALL" = "true" ]; then SERVICE_USER="root" AGENT_BIN_DIR="$LEGACY_INSTALL_DIR" else if [ -n "$EFFECTIVE_USER" ] && [ "$EFFECTIVE_USER" != "root" ] && getent passwd "$EFFECTIVE_USER" >/dev/null 2>&1; then SERVICE_USER="$EFFECTIVE_USER" else SERVICE_USER="$SERVICE_USER_DEFAULT" fi AGENT_BIN_DIR="$INSTALL_DIR" fi if [ "${NODEMESH_INSTALL_SH_SELFTEST:-}" = "1" ]; then # Test-only checkpoint (see internal/web/handlers/install_path_test.go): # print the classification and exit before any filesystem mutation, # user/group creation, or network access. echo "NODEMESH_SELFTEST legacy=${IS_LEGACY_ROOT_INSTALL} deliberate_root=${DELIBERATE_ROOT} effective_user=${EFFECTIVE_USER} service_user=${SERVICE_USER} data_dir=${DATA_DIR} agent_bin_dir=${AGENT_BIN_DIR}" exit 0 fi # ── create/adopt the unprivileged service user ───────────────────────────────── # Games like Satisfactory refuse to run as root; the agent (and everything it # launches) must run as an unprivileged system account instead. Skipped # entirely on a legacy host — see the classification above. if [ "$IS_LEGACY_ROOT_INSTALL" != "true" ]; then echo "-> Ensuring service user '${SERVICE_USER}' exists..." if ! getent passwd "$SERVICE_USER" >/dev/null 2>&1; then if command -v useradd >/dev/null 2>&1; then useradd --system --home-dir /opt/nodemesh/home --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" elif command -v adduser >/dev/null 2>&1; then # Alpine/BusyBox adduser. -G is only honored if the group already exists, # so create it first — BusyBox does not reliably create a same-named # group for a -S -D -H user on its own. if ! getent group "$SERVICE_USER" >/dev/null 2>&1; then addgroup -S "$SERVICE_USER" 2>/dev/null || true fi if getent group "$SERVICE_USER" >/dev/null 2>&1; then adduser -S -D -H -h /opt/nodemesh/home -G "$SERVICE_USER" -s /sbin/nologin "$SERVICE_USER" else adduser -S -D -H -h /opt/nodemesh/home -s /sbin/nologin "$SERVICE_USER" fi else echo "Error: neither useradd nor adduser found — cannot create the '${SERVICE_USER}' service user." >&2 exit 1 fi echo " OK Created system user '${SERVICE_USER}'" else echo " OK System user '${SERVICE_USER}' already exists" fi # Resolve the account's actual primary group rather than assuming it shares # the user's name — useradd's USERGROUPS_ENAB default does this on # Debian/Ubuntu/RHEL, but nothing guarantees it, and BusyBox adduser's # fallback path above may have landed the user in a differently-named group. # Getting this wrong would abort every chown below under `set -eu`. SERVICE_GROUP="$(id -gn "$SERVICE_USER")" echo " OK Service group resolved to '${SERVICE_GROUP}'" # ── probe: can this filesystem represent the target ownership? ─────────── # A custom --data-dir can sit on NTFS/exFAT (no POSIX ownership) or NFS # with root_squash (chown fails even as root) — a real BYOH population # (design R6). Probing BEFORE writing the binary or agent.env means a # failure here leaves nothing installed and no service configured, rather # than aborting under `set -e` mid-install with a half-configured host. mkdir -p "$DATA_ROOT" PROBE_FILE="${DATA_ROOT}/.nodemesh-chown-probe.$$" PROBE_OK=true if ! ( : > "$PROBE_FILE" ) 2>/dev/null; then PROBE_OK=false elif ! chown "${SERVICE_USER}:${SERVICE_GROUP}" "$PROBE_FILE" 2>/dev/null; then PROBE_OK=false else PROBE_OWNER="$(stat -c '%U' "$PROBE_FILE" 2>/dev/null || echo "")" if [ "$PROBE_OWNER" != "$SERVICE_USER" ]; then PROBE_OK=false fi fi rm -f "$PROBE_FILE" 2>/dev/null || true if [ "$PROBE_OK" != "true" ]; then echo "Error: cannot hand ownership of ${DATA_ROOT} to ${SERVICE_USER}:${SERVICE_GROUP}." >&2 echo "This usually means the filesystem backing it (e.g. NTFS/exFAT, or NFS" >&2 echo "with root_squash) cannot represent per-user ownership. Nothing has been" >&2 echo "installed or configured — choose a different --data-dir and re-run." >&2 exit 1 fi fi # ── check for sha256sum ─────────────────────────────────────────────────────── # sha256sum is present on all mainstream Linux distros (coreutils). # We warn and continue rather than aborting if it's absent — keeps installs # working on minimal environments while strongly preferring verification. HAVE_SHA256SUM=false if command -v sha256sum >/dev/null 2>&1; then HAVE_SHA256SUM=true fi # ── download binary ─────────────────────────────────────────────────────────── report_stage download echo "-> Downloading NodeMesh agent (${ARCH})..." TMP_BIN="$(mktemp)" if command -v curl >/dev/null 2>&1; then if ! curl -sSL "$DOWNLOAD_URL" -o "$TMP_BIN"; then report_stage download download.unreachable "$DOWNLOAD_BASE" echo "Error: failed to download the agent from ${DOWNLOAD_URL}." >&2 rm -f "$TMP_BIN" exit 1 fi elif command -v wget >/dev/null 2>&1; then if ! wget -qO "$TMP_BIN" "$DOWNLOAD_URL"; then report_stage download download.unreachable "$DOWNLOAD_BASE" echo "Error: failed to download the agent from ${DOWNLOAD_URL}." >&2 rm -f "$TMP_BIN" exit 1 fi else report_stage download download.unreachable "$DOWNLOAD_BASE" echo "Error: curl or wget is required." >&2 rm -f "$TMP_BIN" exit 1 fi # ── verify SHA-256 checksum ─────────────────────────────────────────────────── # Fetch the SHA256SUMS manifest and verify the downloaded binary. # If the manifest is unavailable (e.g. the deploy hasn't published it yet for # a new release), we print a WARNING and continue rather than breaking installs. # On hash mismatch we always abort — a corrupted binary is never installed. report_stage verify if [ "$HAVE_SHA256SUM" = "true" ]; then echo "-> Verifying SHA-256 checksum..." TMP_SUMS="$(mktemp)" SUMS_FETCHED=false if command -v curl >/dev/null 2>&1; then HTTP_STATUS="$(curl -sSL -o "$TMP_SUMS" -w "%{http_code}" "$SUMS_URL" 2>/dev/null || true)" elif command -v wget >/dev/null 2>&1; then HTTP_STATUS="$(wget -q -O "$TMP_SUMS" "$SUMS_URL" 2>/dev/null && echo "200" || echo "000")" fi if [ "${HTTP_STATUS:-000}" = "200" ] && [ -s "$TMP_SUMS" ]; then SUMS_FETCHED=true fi if [ "$SUMS_FETCHED" = "true" ]; then EXPECTED="$(grep "$BINARY" "$TMP_SUMS" 2>/dev/null | awk '{print $1}')" if [ -z "$EXPECTED" ]; then echo " WARNING: $BINARY not found in SHA256SUMS manifest." >&2 echo " Continuing without verification — manual verification recommended." >&2 else ACTUAL="$(sha256sum "$TMP_BIN" | awk '{print $1}')" if [ "$EXPECTED" = "$ACTUAL" ]; then echo " OK Checksum verified: $ACTUAL" else report_stage verify download.checksum_mismatch echo " ERROR: Checksum mismatch!" >&2 echo " Expected: $EXPECTED" >&2 echo " Got: $ACTUAL" >&2 echo " The downloaded binary has been deleted. Do not retry without investigating." >&2 rm -f "$TMP_BIN" "$TMP_SUMS" exit 1 fi fi else echo " WARNING: SHA256SUMS manifest unavailable (HTTP ${HTTP_STATUS:-???})." >&2 echo " Continuing without checksum verification." >&2 echo " You can verify manually: sha256sum ${AGENT_BIN_DIR}/nodemesh-agent" >&2 echo " Published checksums: ${SUMS_URL}" >&2 fi rm -f "$TMP_SUMS" else echo " WARNING: sha256sum not found — skipping checksum verification." >&2 fi # ── preinstall SteamCMD's 32-bit deps ───────────────────────────────────────── # A non-root agent cannot apt-get/dnf install packages itself (design §4.3), # so this still-root script preinstalls the same package set # pkg/games/steamcmd.go's EnsureSteamCMDDeps would otherwise fetch at runtime. # Best-effort: WARN and continue on failure — a missing 32-bit loader only # blocks SteamCMD games, never the agent itself, and EnsureSteamCMDDeps's # runtime check-then-hint is the backstop. Skipped entirely on a legacy # host: that re-run may be the one rescuing an unhealthy host from a # stranded pairing token, and an unattended apt-get (even non-interactive) # is an avoidable extra failure mode to introduce into that path. if [ "$IS_LEGACY_ROOT_INSTALL" != "true" ]; then echo "-> Checking for SteamCMD's 32-bit runtime libraries..." HAVE_32BIT_LOADER=false for p in /lib/ld-linux.so.2 /lib/i386-linux-gnu/ld-linux.so.2 /lib32/ld-linux.so.2; do if [ -e "$p" ]; then HAVE_32BIT_LOADER=true break fi done if [ "$HAVE_32BIT_LOADER" = "true" ]; then echo " OK 32-bit runtime libraries already present" else echo " -> Attempting to install 32-bit runtime libraries (required by SteamCMD games)..." RUN_TIMEOUT="" if command -v timeout >/dev/null 2>&1; then RUN_TIMEOUT="timeout 120" fi if command -v apt-get >/dev/null 2>&1; then if command -v dpkg >/dev/null 2>&1; then $RUN_TIMEOUT dpkg --add-architecture i386 || true fi DEBIAN_FRONTEND=noninteractive $RUN_TIMEOUT apt-get update -qq || true if ! DEBIAN_FRONTEND=noninteractive $RUN_TIMEOUT apt-get install -y -qq lib32gcc-s1 libc6:i386 2>/dev/null; then # lib32gcc-s1 is the Ubuntu 22+ name; Ubuntu 20 uses lib32gcc1. DEBIAN_FRONTEND=noninteractive $RUN_TIMEOUT apt-get install -y -qq lib32gcc1 libc6:i386 2>/dev/null || \ echo " WARNING: could not install 32-bit libraries via apt-get. SteamCMD games will hint at this on first setup." >&2 fi elif command -v dnf >/dev/null 2>&1; then $RUN_TIMEOUT dnf install -y glibc.i686 libgcc.i686 || \ echo " WARNING: could not install 32-bit libraries via dnf. SteamCMD games will hint at this on first setup." >&2 elif command -v yum >/dev/null 2>&1; then $RUN_TIMEOUT yum install -y glibc.i686 libgcc.i686 || \ echo " WARNING: could not install 32-bit libraries via yum. SteamCMD games will hint at this on first setup." >&2 else echo " WARNING: no supported package manager found (apt-get, dnf, yum). SteamCMD games will hint at this on first setup." >&2 fi fi fi # ── install binary ───────────────────────────────────────────────────────────── # Fresh/adopted host: installed under the service-user-owned INSTALL_DIR (see # comment above) — pkg/selfupdate.Replace() writes a same-directory ".new" # download and a ".bak" backup, then renames over the running binary, which # needs write access to the *directory*, not just the file. # # Legacy host: refreshed IN PLACE at LEGACY_INSTALL_DIR, root-owned, exactly # as this script has always done — no relocation, no chown. Relocating the # binary without also converting ownership/the unit would be a half-conversion. report_stage install if ! mkdir -p "$AGENT_BIN_DIR" || ! mv "$TMP_BIN" "${AGENT_BIN_DIR}/nodemesh-agent" 2>/dev/null; then report_stage install install.write_denied "$AGENT_BIN_DIR" echo "Error: could not write the agent binary to ${AGENT_BIN_DIR}." >&2 rm -f "$TMP_BIN" exit 1 fi # Explicit 0755 regardless of branch — selfupdate.Replace() preserves # whatever mode the file has forever afterwards (it chmods the *new* binary # to match the *current* one on every future update), so getting this wrong # once here persists indefinitely. chmod 0755 "${AGENT_BIN_DIR}/nodemesh-agent" if [ "$IS_LEGACY_ROOT_INSTALL" = "true" ]; then echo " OK Refreshed ${AGENT_BIN_DIR}/nodemesh-agent (left root-owned — legacy host)" else chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "$INSTALL_DIR" echo " OK Installed to ${INSTALL_DIR}/nodemesh-agent (owned by ${SERVICE_USER}:${SERVICE_GROUP})" fi # ── create config ───────────────────────────────────────────────────────────── echo "-> Writing config to ${CONFIG_DIR}/agent.env..." mkdir -p "$CONFIG_DIR" if [ "$IS_LEGACY_ROOT_INSTALL" = "true" ]; then # NODEMESH_RUNTIMES_DIR is deliberately omitted here — a legacy host's # runtimes already live wherever its root-owned agent resolved them # (runtimesBaseDir()'s exe-relative fallback from LEGACY_INSTALL_DIR); # writing a different path would silently redirect an existing host's # SteamCMD/Java installs out from under it. cat > "${CONFIG_DIR}/agent.env" < "${CONFIG_DIR}/agent.env" <, cmd/agent/main.go:236-252) are NOT chowned by this # script, because the installer has no way to know about them — they are # only discovered by the agent at runtime, per-instance. A fresh non-root # agent therefore cannot create an instance directory on such a mount until # something chowns {mount}/nodemesh for it; that gap is intentionally left # open here rather than guessed at, pending an agent-side fix (e.g. chown-on- # first-use) that is out of this script's scope. if [ "$IS_LEGACY_ROOT_INSTALL" = "true" ]; then echo " Skipping data-tree ownership change — host stays root-owned" if [ "$DELIBERATE_ROOT" = "true" ]; then # Record the choice durably — see "OPTING INTO ROOT" at the top of this # file. A plain re-run (no --as-root, no NODEMESH_SERVICE_USER=root) must # still find this marker and stay root, never silently converting. mkdir -p "$(dirname "$AS_ROOT_MARKER")" : > "$AS_ROOT_MARKER" fi else mkdir -p "$DATA_ROOT" "$RUNTIMES_DIR" /opt/nodemesh/home chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "$DATA_ROOT" /opt/nodemesh/home chmod 0750 /opt/nodemesh/home echo " OK ${DATA_ROOT} and /opt/nodemesh/home owned by ${SERVICE_USER}:${SERVICE_GROUP}" # Housekeeping only — not a privilege-conversion step: if this host's # EFFECTIVE User= is already non-root (an operator's own fix, adopted # above), a stale AS_ROOT_MARKER from an earlier deliberate-root choice no # longer reflects reality. The actual privilege state already changed by # the operator's own hand; this just stops it from confusing the NEXT # re-run's classification. rm -f "$AS_ROOT_MARKER" 2>/dev/null || true fi # ── install systemd service ─────────────────────────────────────────────────── echo "-> Installing systemd service..." if [ "$IS_LEGACY_ROOT_INSTALL" = "true" ]; then # Unchanged from before this work: no User=/Group= — adding either without # also having converted ownership (skipped above) would be exactly the # half-conversion this script must not do. Still rewritten/reloaded/ # restarted every run, same as always — this is what upgrades a # pre-v0.8.1 host's Restart=on-failure to Restart=always on a plain # re-run, independent of the non-root work. cat > "$SERVICE_FILE" < "$SERVICE_FILE" <&1)" echo "Error: nodemesh-agent did not reach the active state." >&2 echo " Check status: systemctl status nodemesh-agent" >&2 echo " View logs: journalctl -u nodemesh-agent -f" >&2 exit 1 fi # ── wait for the control plane to confirm the pairing ───────────────────────── # `systemctl is-active` proves the process started. It proves NOTHING about # pairing: an agent given an invalid or expired token starts perfectly and then # loops forever on "Unauthenticated: invalid or expired pairing token". This # script used to call `report_stage paired` right here and print "OK NodeMesh # agent is running." over exactly that host — the same false success install.ps1 # had, and the reason the panel's "Add Host" page waited for a machine that was # never coming. # # journalctl WOULD carry the answer on Linux, but the control plane is the party # that actually decides, its verdict is the same one the panel renders, and it is # the only source install.ps1 can share (a Windows service has no log sink) — so # both scripts ask the same endpoint rather than diverging into two mechanisms. echo "-> Waiting for the control plane to confirm pairing (up to ${PAIR_TIMEOUT_SEC}s)..." PAIR_OUTCOME=pending PAIR_REACHABLE=true PAIR_ELAPSED=0 while [ "$PAIR_ELAPSED" -lt "$PAIR_TIMEOUT_SEC" ]; do PAIR_OUTCOME="$(pairing_outcome)" if [ "$PAIR_OUTCOME" = paired ] || [ "$PAIR_OUTCOME" = rejected ]; then break fi if [ "$PAIR_OUTCOME" = unreachable ]; then PAIR_REACHABLE=false; else PAIR_REACHABLE=true; fi sleep "$PAIR_POLL_SEC" PAIR_ELAPSED=$((PAIR_ELAPSED + PAIR_POLL_SEC)) done if [ "$PAIR_OUTCOME" = rejected ]; then report_stage paired connect.rejected "control plane rejected the pairing token" echo "" >&2 echo "Error: the control plane rejected this pairing token." >&2 echo " The token is invalid, expired (they last 24h), or already used by another machine." >&2 echo " Fix: open https://nodemesh.app/hosts, click 'Add Host' to generate a NEW token," >&2 echo " then re-run this installer with it. The agent service is installed and will" >&2 echo " pick up the new token automatically once you re-run." >&2 exit 1 fi if [ "$PAIR_OUTCOME" != paired ]; then if [ "$PAIR_REACHABLE" = false ]; then report_stage paired connect.unreachable "installer could not reach ${NODEMESH_STATUS_URL}" echo "" >&2 echo "Error: could not reach NodeMesh to confirm pairing." >&2 echo " This machine downloaded the agent but cannot talk to nodemesh.app now." >&2 echo " Fix: check DNS, an HTTP proxy, or an egress firewall blocking outbound HTTPS," >&2 echo " then re-run this installer. Nothing needs uninstalling first." >&2 else report_stage paired connect.timeout "no pairing verdict within ${PAIR_TIMEOUT_SEC}s" echo "" >&2 echo "Error: the agent started but never connected within ${PAIR_TIMEOUT_SEC}s." >&2 echo " The service is running and the token was not rejected, so the agent most likely" >&2 echo " cannot reach the control plane on ${BACKEND_ADDR}." >&2 echo " Fix: allow outbound TCP 443 to ${BACKEND_ADDR}, then: systemctl restart nodemesh-agent" >&2 echo " Diagnose: journalctl -u nodemesh-agent -n 50" >&2 echo " The host will appear on https://nodemesh.app/hosts by itself if it connects later." >&2 fi exit 1 fi report_stage paired echo "" if [ "$IS_LEGACY_ROOT_INSTALL" = "true" ]; then echo "OK NodeMesh agent is running and paired (still as root — see the recipe above to secure this host)." else echo "OK NodeMesh agent is running and paired (resource limits are NOT enforced yet — Slice 4 is required for that; see release notes)." fi echo " View logs: journalctl -u nodemesh-agent -f" echo " Check status: systemctl status nodemesh-agent" echo " Uninstall: curl -sSL https://nodemesh.app/uninstall.sh | sudo sh"