#!/usr/bin/env bash # monsys-agent installer (Linux). # Usage: curl -fsSL https://get.monsys.ai/install.sh | sudo MONSYS_TOKEN= bash # # `sudo VAR=val bash` passes MONSYS_TOKEN explicitly via sudo's command # line so the root-shell sees it. `VAR=val curl ... | sudo -E bash` puts # VAR only in curl's env (NOT the calling shell's), so `sudo -E` has # nothing to preserve and the script bails with "MONSYS_TOKEN is required". set -euo pipefail MONSYS_HUB_URL="${MONSYS_HUB_URL:-https://api.monsys.ai}" # MONSYS_TOKEN is required ONLY for fresh installs. If /etc/monsys/agent.toml # already exists and holds a valid agent_id, we treat the run as a repair # (re-install of binary / sudoers / wrappers / systemd unit) and DON'T # require a token — exchanging one would consume an enrolment use for # nothing and disturb the host's existing identity. if [[ -z "${MONSYS_TOKEN:-}" ]] && [[ ! -f /etc/monsys/agent.toml ]]; then echo "MONSYS_TOKEN is required (fresh install)" >&2 exit 1 fi MONSYS_TOKEN="${MONSYS_TOKEN:-}" VERSION="${MONSYS_VERSION:-latest}" ARCH="$(uname -m)" case "$ARCH" in x86_64|amd64) ARCH=x64 ;; aarch64|arm64) ARCH=arm64 ;; *) echo "unsupported arch $ARCH" >&2; exit 1 ;; esac OS="$(uname -s | tr '[:upper:]' '[:lower:]')" if [[ "$EUID" -ne 0 ]]; then echo "must run as root (use sudo)" >&2; exit 1 fi echo "→ creating monsys user/dirs" useradd -r -s /bin/false -d /var/lib/monsys monsys 2>/dev/null || true install -d -o monsys -g monsys -m 0755 /var/lib/monsys /var/lib/monsys/dumps /var/lib/monsys/quarantine /var/log/monsys install -d -o root -g root -m 0755 /etc/monsys # If docker is installed, give monsys read access so the container collector # can `docker inspect` running containers. We add the group ONLY if it exists # (no-op on hosts without docker). if getent group docker >/dev/null 2>&1; then usermod -aG docker monsys echo " added monsys to docker group" fi echo "→ creating monsys-console user (Sessie 6b — Emergency Console)" useradd -r -s /bin/bash -d /var/lib/monsys/console -c "Monsys Emergency Console" monsys-console 2>/dev/null || true install -d -o monsys-console -g monsys-console -m 0750 /var/lib/monsys/console cat > /var/lib/monsys/console/.bashrc <<'BASHRC' # Monsys Emergency Console — alle invoer wordt geregistreerd PS1='\[\033[01;31m\][MONSYS-CONSOLE]\[\033[00m\] \u@\h:\w\$ ' export HISTFILE=/var/lib/monsys/console/.bash_history export HISTTIMEFORMAT="%F %T " BASHRC chown monsys-console:monsys-console /var/lib/monsys/console/.bashrc echo "→ downloading agent ${VERSION} (${OS}-${ARCH})" # Download to a temp file and atomic-rename into place. Writing directly # to /usr/local/bin/monsys-agent fails with ETXTBSY when the agent # service is currently running (Linux refuses to truncate a running # executable). The rename atomically swaps the inode; the running # process keeps its old in-memory copy until the next restart. NEW_BIN="$(mktemp /tmp/monsys-agent.dl.XXXX)" curl -fsSL "https://get.monsys.ai/releases/${VERSION}/monsys-agent-${OS}-${ARCH}" -o "$NEW_BIN" chmod 0755 "$NEW_BIN" chown root:root "$NEW_BIN" mv -f "$NEW_BIN" /usr/local/bin/monsys-agent # Re-run safety: if any existing config / persisted identity is on disk, # treat this as a repair run — do NOT consume a new enrolment token # (would orphan the existing agent row in the hub). The wrappers / # sudoers / systemd unit get reinstalled fresh either way. # # Detection layers (any one is enough): # 1. agent_id = "..." inside /etc/monsys/agent.toml (modern installs) # 2. /var/lib/monsys/agent-id (older installs persisted it separately) # 3. /etc/monsys/agent.toml exists at all (assume repair; don't touch toml) EXISTING_ID="" if [[ -f /etc/monsys/agent.toml ]]; then EXISTING_ID=$(awk -F'"' '/^[[:space:]]*agent_id[[:space:]]*=/ {print $2; exit}' /etc/monsys/agent.toml 2>/dev/null || true) fi if [[ -z "$EXISTING_ID" && -f /var/lib/monsys/agent-id ]]; then EXISTING_ID=$(tr -d '\n\r ' < /var/lib/monsys/agent-id 2>/dev/null || true) fi EXISTING_INSTALL="" if [[ -n "$EXISTING_ID" || -f /etc/monsys/agent.toml ]]; then EXISTING_INSTALL="1" fi if [[ -n "$EXISTING_INSTALL" ]]; then echo "→ existing install detected (agent_id=${EXISTING_ID:-unknown}) — preserving credentials" AGENT_ID="${EXISTING_ID:-unknown}" AGENT_TOKEN="" # signals: don't rewrite agent.toml below else echo "→ swapping enrolment token for per-agent credentials" # The enrolment token (enrl_…) is multi-use and tenant-scoped. It must be # exchanged for a single-host agent_token before the service starts; # otherwise /agents/register 401s and the agent never appears in the # dashboard. Anything but a 200 here aborts the install — silent failure # leaves a half-broken service that journalctl says is "active" but the # hub will never see. ENROL_BODY=$(curl -fsS -X POST "${MONSYS_HUB_URL}/api/v1/agents/enrol" \ -H 'Content-Type: application/json' \ --data "{\"enrolment_token\":\"${MONSYS_TOKEN}\",\"hostname\":\"$(hostname)\"}" \ || true) AGENT_TOKEN=$(printf '%s' "$ENROL_BODY" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') AGENT_ID=$(printf '%s' "$ENROL_BODY" | sed -n 's/.*"agent_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') if [[ -z "$AGENT_TOKEN" || -z "$AGENT_ID" ]]; then echo "✗ enrolment failed — hub response: ${ENROL_BODY:-}" >&2 echo " Verify MONSYS_TOKEN is correct and not exhausted (admin → enrolment tokens)." >&2 exit 1 fi fi if [[ -n "$AGENT_TOKEN" ]]; then echo "→ writing /etc/monsys/agent.toml" cat > /etc/monsys/agent.toml < /var/lib/monsys/agent-id chown monsys:monsys /var/lib/monsys/agent-id chmod 0640 /var/lib/monsys/agent-id echo "→ installing sudoers" # Every privileged command the agent needs runs through one of two narrow # entry points: # 1) Fixed-argument iptables rules for IsolateNetwork/RestoreNetwork — # no wildcards, the agent passes the SAME positional args every time. # 2) The `monsys-action` wrapper for KillProcess / StopService / # StartService / Reboot / InstallPackage. The wrapper validates each # argument against an allowlist regex before exec'ing — so a # compromise of the monsys uid (or a console-shell escape) cannot # sudo arbitrary `apt-get install -y ./malicious.deb` or # `systemctl start /etc/systemd/system/x.service`. # # Previously this file allowed `apt-get install -y *`, `systemctl start *`, # `kill -9 *` etc. directly — those wildcards were equivalent to NOPASSWD # root because dpkg maintainer scripts execute as root. install -m 0440 -o root -g root /dev/stdin /etc/sudoers.d/monsys <<'SUDOERS' Defaults:monsys !requiretty # IsolateNetwork — exact iptables command shapes, no wildcards. monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -P INPUT DROP monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -P OUTPUT DROP monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -P FORWARD DROP monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -F monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -A INPUT -i lo -j ACCEPT monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -A OUTPUT -o lo -j ACCEPT monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -A OUTPUT -d * -p tcp --dport 443 -j ACCEPT monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -A INPUT -s * -m state --state ESTABLISHED -j ACCEPT # Same set for IPv6 (ip6tables; only used when hub_url is an IPv6 literal). monsys ALL=(ALL) NOPASSWD: /usr/sbin/ip6tables -A OUTPUT -d * -p tcp --dport 443 -j ACCEPT monsys ALL=(ALL) NOPASSWD: /usr/sbin/ip6tables -A INPUT -s * -m state --state ESTABLISHED -j ACCEPT monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables-save monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables-restore /var/lib/monsys/iptables-backup.rules # All other privileged actions go through monsys-action (single entry). monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-action kill * monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-action service-start * monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-action service-stop * monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-action reboot monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-action install * monsys ALL=(ALL) NOPASSWD: /usr/bin/avml /var/lib/monsys/dumps/* # Agent self-update (auto_update_enabled=true). monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-update apply * monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-update apply-hub * # Application-dependency upgrade (npm/yarn/pip/composer/...) — wrapper # validates project_path, manifest, package names, versions, optional # service unit. Snapshots + rolls back on failure. # NOTE — wildcard `*` in sudoers matches ONE argument (no whitespace). # monsys-package-update takes variable-length argv (project + manifest # + N packages + optional --restart svc), so we MUST omit any trailing # pattern entirely. Empty arg-list = any args allowed. The wrapper # itself re-validates every argument against an allowlist regex. monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-package-update # Restart a registered application by app_id (uuid-only). Wrapper looks # the app up in /var/lib/monsys/apps.json (written by the agent on each # whoami refresh) and dispatches systemctl/docker/compose. monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-action restart-app * SUDOERS echo "→ installing monsys-action wrapper" install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/monsys-action <<'WRAPPER' #!/usr/bin/env bash # monsys-action — privileged wrapper for emergency-token actions. # The agent (which runs as monsys uid) calls this via sudo. Every # parameter is validated here BEFORE exec'ing the underlying command, # so an attacker who has the monsys uid but no signed token cannot # install arbitrary packages or kill init. set -euo pipefail die() { echo "monsys-action: $*" >&2; exit 64; } need() { command -v "$1" >/dev/null || die "missing tool: $1"; } action="${1:-}"; shift || true valid_name='^[a-zA-Z0-9][a-zA-Z0-9._@:+-]{0,127}$' valid_version='^[a-zA-Z0-9][a-zA-Z0-9._:+~-]{0,63}$' valid_pid='^[1-9][0-9]{0,9}$' case "$action" in kill) pid="${1:-}" [[ "$pid" =~ $valid_pid ]] || die "bad pid: $pid" [[ "$pid" -le 1 ]] && die "refusing to kill init" exec /bin/kill -9 -- "$pid" ;; service-start|service-stop) name="${1:-}" [[ "$name" =~ $valid_name ]] || die "bad service name: $name" # Disallow service names that name a path (slashes blocked by regex # already, but defense-in-depth). case "$name" in */*) die "service path not allowed";; esac verb="${action#service-}" need /bin/systemctl exec /bin/systemctl "$verb" -- "$name" ;; reboot) need /sbin/shutdown exec /sbin/shutdown -r +1 "monsys emergency reboot" ;; install) name="${1:-}"; version="${2:-}" [[ "$name" =~ $valid_name ]] || die "bad package name: $name" [[ "$version" =~ $valid_version ]] || die "bad package version: $version" if command -v apt-get >/dev/null; then # `--` blocks --reinstall etc; positional pkg=version is safe per dpkg. exec /usr/bin/apt-get install -y -- "$name=$version" fi if command -v dnf >/dev/null; then exec /usr/bin/dnf install -y -- "$name-$version" fi die "no supported package manager" ;; restart-app) # Look up app by id in /var/lib/monsys/apps.json (written by the # agent on each whoami refresh). The file is a JSON array of # {id, type, identifier, restart_method, restart_args} objects. app_id="${1:-}" [[ "$app_id" =~ ^[a-zA-Z0-9-]{1,64}$ ]] || die "bad app_id" need /usr/bin/jq cache=/var/lib/monsys/apps.json [[ -r "$cache" ]] || die "no apps cache at $cache" row=$(jq -c --arg id "$app_id" '.[] | select(.id == $id)' "$cache") [[ -n "$row" ]] || die "app $app_id not registered" typ=$(echo "$row" | jq -r '.type') ident=$(echo "$row" | jq -r '.identifier') case "$typ" in systemd) [[ "$ident" =~ $valid_name ]] || die "bad systemd unit name in cache" exec /bin/systemctl restart -- "$ident" ;; docker) [[ "$ident" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$ ]] || die "bad container name" need /usr/bin/docker exec /usr/bin/docker restart "$ident" ;; compose) # ident is "/abs/path/docker-compose.yml::service_name" file="${ident%::*}"; svc="${ident##*::}" [[ "$file" =~ ^/[A-Za-z0-9._/-]+\.(yml|yaml)$ ]] || die "bad compose file" [[ "$svc" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$ ]] || die "bad compose service" need /usr/bin/docker exec /usr/bin/docker compose -f "$file" restart "$svc" ;; *) die "unsupported app type: $typ" ;; esac ;; *) die "unknown action: $action" ;; esac WRAPPER echo "→ installing monsys-package-update wrapper" install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/monsys-package-update <<'PKGUP' #!/usr/bin/env bash # monsys-package-update — privileged dependency-upgrade wrapper. # Args: [restart_service] # # Validates every argument against allowlist regexes, snapshots the lockfile # (and node_modules for npm/yarn/pnpm) by renaming, runs the right PM, and # rolls back the snapshot if (a) the PM exits non-zero or (b) the optional # `systemctl restart $unit` fails afterwards. set -uo pipefail die() { echo "monsys-package-update: $*" >&2; exit 64; } # Strip --force from argv early so the rest of the script can keep its # positional parsing. --force opts in to --legacy-peer-deps (npm) / # --strict-peer-dependencies=false (pnpm) — never default-on; tenant # must request it explicitly per EAT. force_resolve=0 new_argv=() for a in "$@"; do if [[ "$a" == "--force" ]]; then force_resolve=1 else new_argv+=("$a") fi done set -- "${new_argv[@]}" project_path="${1:-}" manifest="${2:-}" packages_arg="${3:-}" restart_service="${4:-}" # ── input validation ───────────────────────────────────────────────── [[ -n "$project_path" ]] || die "project_path required" [[ "$project_path" =~ ^/[A-Za-z0-9._/-]{1,255}$ ]] || die "bad project_path" [[ "$project_path" =~ \.\. ]] && die "path traversal" [[ -d "$project_path" ]] || die "project_path does not exist or is not a dir" case "$manifest" in package-lock.json|yarn.lock|pnpm-lock.yaml|requirements.txt|Pipfile.lock|composer.lock|Gemfile.lock|go.sum) ;; *) die "unknown manifest: $manifest" ;; esac [[ -f "$project_path/$manifest" ]] || die "manifest $manifest not found in $project_path" # packages_arg = "name1@ver1,name2@ver2,..." [[ -n "$packages_arg" ]] || die "packages list required" valid_pkg='^[a-zA-Z0-9@][a-zA-Z0-9._@/-]{0,127}@[A-Za-z0-9][A-Za-z0-9._:+~-]{0,63}$' IFS=',' read -ra entries <<< "$packages_arg" [[ ${#entries[@]} -ge 1 && ${#entries[@]} -le 50 ]] || die "packages count out of range" for e in "${entries[@]}"; do [[ "$e" =~ $valid_pkg ]] || die "bad package spec: $e" done if [[ -n "$restart_service" ]]; then [[ "$restart_service" =~ ^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,63}$ ]] || die "bad restart_service" fi # ── snapshot ───────────────────────────────────────────────────────── ts=$(date -u +%Y%m%d-%H%M%S) bak_suffix=".monsys-bak-$ts" snapshots=() snap() { local f="$1" if [[ -e "$f" ]]; then mv -- "$f" "${f}${bak_suffix}" snapshots+=("$f") fi } rollback() { echo "monsys-package-update: ROLLBACK" >&2 for f in "${snapshots[@]}"; do rm -rf -- "$f" mv -- "${f}${bak_suffix}" "$f" || true done # Exit non-zero so the agent sees this as a failure. Without this the # ERR trap restored the lockfile but the script kept going to the OK # echo + implicit exit 0 → the dashboard's "Recente acties" panel # showed `OK / 13 packages upgraded` while npm had actually rolled back. exit 65 } trap 'rollback' ERR cd "$project_path" snap "$manifest" case "$manifest" in package-lock.json|yarn.lock|pnpm-lock.yaml) snap "node_modules" ;; esac # ── install ────────────────────────────────────────────────────────── # Build (name, version) pairs. pm_args=() for e in "${entries[@]}"; do name="${e%@*}" ver="${e##*@}" pm_args+=("$name" "$ver") done # Supply-chain hardening: block lifecycle scripts by default. Set the # env var MONSYS_NPM_ALLOW_SCRIPTS=1 to opt out (a few packages — esbuild, # sharp, sqlite3, fsevents — legitimately need postinstall to compile # native bindings; agent reads this from /etc/monsys/policy.conf which # tracks the tenant_settings.npm_allow_install_scripts boolean). NPM_SCRIPT_FLAG="--ignore-scripts" COMPOSER_SCRIPT_FLAG="--no-scripts" if [[ "${MONSYS_NPM_ALLOW_SCRIPTS:-0}" = "1" ]]; then NPM_SCRIPT_FLAG="" COMPOSER_SCRIPT_FLAG="" echo "monsys-package-update: WARNING install scripts ENABLED (tenant opt-in)" >&2 fi # --force from caller → pass --legacy-peer-deps to bypass peer-dep # conflicts. Logged loudly because silently accepting broken peer-deps # can mask runtime bugs that only surface much later. NPM_PEER_FLAG="" PNPM_PEER_FLAG="" if [[ "$force_resolve" = "1" ]]; then NPM_PEER_FLAG="--legacy-peer-deps" PNPM_PEER_FLAG="--strict-peer-dependencies=false" echo "monsys-package-update: WARNING force=1 — peer-dep conflicts will be IGNORED" >&2 fi # Tool-availability check. Exit 66 (TOOL_MISSING) — distinct from exit # 65 (ROLLBACK after failed install). The hub generator filters future # EATs for any (agent, project_path) combo that previously failed with # 66, so we stop re-queueing PHP-update EATs for hosts that never had # composer installed in the first place. require_tool() { local tool="$1" path="$2" if [[ ! -x "$path" ]]; then # Disable the rollback trap so the snapshot doesn't get restored # twice + skip the lockfile cp-back (we never touched anything yet). trap - ERR for f in "${snapshots[@]}"; do [[ -e "${f}${bak_suffix}" ]] && mv -- "${f}${bak_suffix}" "$f" || true done echo "monsys-package-update: tool_missing: $tool not installed at $path" >&2 exit 66 fi } case "$manifest" in package-lock.json) require_tool npm /usr/bin/npm require_tool jq /usr/bin/jq # Restore the snapshotted lockfile contents before install — npm needs # the structure even though it'll rewrite it. cp -p "${manifest}${bak_suffix}" "$manifest" npm_specs=() i=0 while (( i < ${#pm_args[@]} )); do npm_specs+=("${pm_args[i]}@${pm_args[i+1]}") i=$((i+2)) done # Inject `overrides` for every (name, version) pair into package.json so # npm forces the target version into nested/transitive dependencies too. # Without this, `npm install --save form-data@2.5.4` updates only the # top-level entry; any nested form-data 2.3.3 (pulled in by another dep) # stays at 2.3.3 and the CVE persists. Snapshot package.json first so # rollback restores it. if [[ -f package.json ]]; then snap "package.json" # `jq --argjson` requires a JSON object, build it via repeated --arg. tmp_pkg=$(mktemp) jq_args=() i=0 while (( i < ${#pm_args[@]} )); do jq_args+=(--arg "k$((i/2))" "${pm_args[i]}" --arg "v$((i/2))" "${pm_args[i+1]}") i=$((i+2)) done # Build the overrides object inline using the --arg pairs. ov_expr='.overrides = (.overrides // {})' i=0 while (( i < ${#pm_args[@]} )); do ov_expr+=" | .overrides[\$k$((i/2))] = \$v$((i/2))" i=$((i+2)) done jq "${jq_args[@]}" "$ov_expr" "package.json" > "$tmp_pkg" && mv "$tmp_pkg" package.json fi /usr/bin/npm install --save $NPM_SCRIPT_FLAG $NPM_PEER_FLAG "${npm_specs[@]}" # Dedupe: collapse identical sub-trees. Reduces final node_modules size # and ensures overridden packages bubble up to one canonical location. /usr/bin/npm dedupe $NPM_SCRIPT_FLAG || true ;; yarn.lock) require_tool yarn /usr/bin/yarn cp -p "${manifest}${bak_suffix}" "$manifest" yarn_specs=() i=0 while (( i < ${#pm_args[@]} )); do yarn_specs+=("${pm_args[i]}@${pm_args[i+1]}") i=$((i+2)) done # yarn classic: --ignore-scripts; yarn berry: --mode=skip-build (we # bias to classic flag — berry users can opt out via env var). /usr/bin/yarn upgrade --exact $NPM_SCRIPT_FLAG "${yarn_specs[@]}" ;; pnpm-lock.yaml) require_tool pnpm /usr/bin/pnpm cp -p "${manifest}${bak_suffix}" "$manifest" pnpm_specs=() i=0 while (( i < ${#pm_args[@]} )); do pnpm_specs+=("${pm_args[i]}@${pm_args[i+1]}") i=$((i+2)) done /usr/bin/pnpm update $NPM_SCRIPT_FLAG $PNPM_PEER_FLAG "${pnpm_specs[@]}" ;; requirements.txt) require_tool pip /usr/bin/pip pip_specs=() i=0 while (( i < ${#pm_args[@]} )); do pip_specs+=("${pm_args[i]}==${pm_args[i+1]}") i=$((i+2)) done /usr/bin/pip install --upgrade "${pip_specs[@]}" ;; composer.lock) require_tool composer /usr/bin/composer composer_specs=() i=0 while (( i < ${#pm_args[@]} )); do composer_specs+=("${pm_args[i]}:${pm_args[i+1]}") i=$((i+2)) done /usr/bin/composer require --update-with-dependencies $COMPOSER_SCRIPT_FLAG "${composer_specs[@]}" ;; *) die "unsupported manifest for auto-update: $manifest" ;; esac # ── post-install verify ────────────────────────────────────────────── # npm install can report exit 0 while silently leaving the version # unchanged (peer-dep skip, hidden lock pin, --legacy-peer-deps fallthrough). # Re-read the lockfile and confirm every requested (name, target) is # present at exactly that version. Exit 67 (VERIFY_FAILED) so the hub # distinguishes "ran but didn't actually update" from "rolled back". case "$manifest" in package-lock.json|yarn.lock|pnpm-lock.yaml) # jq is already required for the overrides step above on npm; for # yarn/pnpm we may need it here for the first time. Check now. if [[ -x /usr/bin/jq ]]; then mismatches="" i=0 while (( i < ${#pm_args[@]} )); do name="${pm_args[i]}" want="${pm_args[i+1]}" # Walk all `packages[*]` and `dependencies[*]` entries in # package-lock.json v2/v3 looking for this name. if [[ "$manifest" == "package-lock.json" ]]; then got=$(/usr/bin/jq -r --arg n "$name" ' [ ( .packages // {} ) | to_entries[] | select(.key | endswith("node_modules/" + $n)) | .value.version ] + [ ( .dependencies // {} ) | to_entries[] | select(.key == $n) | .value.version ] | first // empty ' "$manifest" 2>/dev/null) else # yarn.lock / pnpm-lock.yaml: best-effort grep. got=$(grep -E "^[^#]*${name//\//\\/}[@\":]" "$manifest" 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) fi if [[ -z "$got" ]]; then mismatches+=" ${name}@want:${want}/got:absent" elif [[ "$got" != "$want" ]]; then mismatches+=" ${name}@want:${want}/got:${got}" fi i=$((i+2)) done if [[ -n "$mismatches" ]]; then # Roll back inline rather than calling rollback() which exits 65. # We want exit 67 (VERIFY_FAILED) so the hub can distinguish # "wrapper ran without errors but didn't actually achieve the # target version" from "install errored and was rolled back". trap - ERR echo "monsys-package-update: VERIFY_FAILED post-install lockfile does not match targets:${mismatches}" >&2 echo "monsys-package-update: ROLLBACK" >&2 for f in "${snapshots[@]}"; do rm -rf -- "$f" mv -- "${f}${bak_suffix}" "$f" || true done exit 67 fi fi ;; esac # ── restart (optional) ─────────────────────────────────────────────── if [[ -n "$restart_service" ]]; then if ! /bin/systemctl restart -- "$restart_service"; then echo "monsys-package-update: restart of $restart_service failed; rolling back" >&2 rollback exit 70 fi fi # ── success ────────────────────────────────────────────────────────── trap - ERR for f in "${snapshots[@]}"; do rm -rf -- "${f}${bak_suffix}" done echo "monsys-package-update: OK" PKGUP echo "→ installing monsys-update wrapper" # Self-update wrapper. The agent download path stages the new binary at # /var/lib/monsys/staging/monsys-agent.new and then calls # `sudo /usr/local/sbin/monsys-update apply `. The wrapper # validates the staged file, does an atomic swap, and restarts the # service. Sudoers fragment above pins this exact invocation. install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/monsys-update <<'UPDWRAP' #!/usr/bin/env bash set -euo pipefail die() { echo "monsys-update: $*" >&2; exit 64; } [[ $# -ge 2 ]] || die "usage: monsys-update apply | apply-hub " verb="$1"; shift target="/usr/local/bin/monsys-agent" case "$verb" in apply) target="/usr/local/bin/monsys-agent" ;; apply-hub) target="/usr/local/bin/monsys-hub" ;; *) die "unknown verb: $verb" ;; esac staged="$1" [[ "$staged" = /var/lib/monsys/staging/* ]] || die "staged path must live under /var/lib/monsys/staging" [[ -f "$staged" && -s "$staged" ]] || die "staged binary missing or empty: $staged" # Sanity: must be an ELF (or PE for hub-on-windows; we only target Linux here). read -r -n 4 head < <(head -c 4 "$staged" | od -An -c | tr -d ' ') || true file -b "$staged" | grep -qE 'ELF' || die "staged file is not an ELF binary" # Defence: keep the previous version next to it so an operator can roll # back manually (mv .bak .). We deliberately do NOT auto-rollback on # service-restart failure — that hides the real bug and the service # would silently flap between versions. if [[ -f "$target" ]]; then cp -a "$target" "${target}.bak" fi # Atomic swap via rename on the same filesystem. install -m 0755 -o root -g root "$staged" "${target}.new" mv -f "${target}.new" "$target" rm -f "$staged" # Restart the matching unit. case "$verb" in apply) /bin/systemctl restart monsys-agent ;; apply-hub) /bin/systemctl restart monsys-hub ;; esac echo "monsys-update: $verb OK → $target" UPDWRAP # The wrapper needs to restart the service it just upgraded. The sudoers # entry at the top of this file only NOPASSWDs `/usr/local/sbin/monsys-update` # itself — but the wrapper runs as root already (sudoers gave us that), # so it can systemctl restart directly without re-sudoing. # Plant honeypot canaries ONLY when none exist yet. Re-planting on a # repair-run touches the existing files, which the running canary watcher # treats as "someone accessed our canary" → spurious HONEYPOT TRIGGERED # warnings in the security feed. Skip when /etc/monsys/.canary already # exists (placed by the first run). if [[ -f /etc/monsys/.canary ]]; then echo "→ honeypot canaries already in place — skipping re-plant" else echo "→ planting honeypot canaries" sudo -u monsys -- /usr/local/bin/monsys-agent --setup-honeypots --config /etc/monsys/agent.toml >/dev/null 2>&1 || true fi # Belt-and-braces: any artefact below /var/lib/monsys/ that ended up root-owned # (e.g. from earlier installer runs) gets normalised here. The monsys-console # subdir keeps its own ownership. echo "→ normalising file ownership under /var/lib/monsys" find /var/lib/monsys/ -mindepth 1 -maxdepth 1 ! -name console -exec chown -R monsys:monsys {} + echo "→ installing systemd unit" install -m 0644 -o root -g root /dev/stdin /etc/systemd/system/monsys-agent.service <<'UNIT' [Unit] Description=Monsys System Monitoring Agent After=network-online.target Wants=network-online.target [Service] Type=simple User=monsys ExecStart=/usr/local/bin/monsys-agent --config /etc/monsys/agent.toml Restart=always RestartSec=10 StandardOutput=journal StandardError=journal SyslogIdentifier=monsys-agent # NOTE — DO NOT add CapabilityBoundingSet here. The agent itself runs as # the unprivileged `monsys` user with zero caps; everything elevated goes # through setuid sudo wrappers (monsys-action / monsys-package-update / # monsys-update). A bounding set that doesn't include CAP_SETUID/SETGID/ # AUDIT_WRITE breaks sudo with "unable to change to root gid" and makes # self-update + dep upgrades silently fail. [Install] WantedBy=multi-user.target UNIT systemctl daemon-reload systemctl enable --now monsys-agent # Verify the agent reached the hub. The enrolment already created the # agent row, but a config/firewall/clock skew could still keep the # service from registering its hostname or signing pubkey. Poll # journalctl briefly and bail loudly if we never see a successful # register/whoami round-trip — otherwise the operator walks away thinking # everything is fine while the hub never hears from this box. echo "→ verifying agent reached hub (timeout 25s)" ok=0 for _ in $(seq 1 25); do sleep 1 # Two acceptable signals: (a) a fresh transition (WS connect, register, # inventory push) in the last 40s, OR (b) any agent log line in the # last 5 minutes that proves the binary is alive and connected — covers # the repair-run case where the WS was already connected before the # install and nothing new transitioned during the wait. if journalctl -u monsys-agent --since '40 seconds ago' --no-pager 2>/dev/null \ | grep -qE 'emergency websocket connected|register success|inventory pushed'; then ok=1; break fi if journalctl -u monsys-agent --since '5 minutes ago' --no-pager 2>/dev/null \ | grep -qE 'emergency websocket connected|inventory pushed|cert inventory|agent signals'; then ok=1; break fi done echo if [[ "$ok" -eq 1 ]]; then echo "✓ monsys-agent installed and connected (agent_id=${AGENT_ID})." else echo "⚠ monsys-agent installed but did NOT confirm hub connectivity within 12s." echo " Inspect: journalctl -u monsys-agent -n 50" fi echo " status: systemctl status monsys-agent" echo " logs: journalctl -u monsys-agent -f"