#!/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/quarantine /var/log/monsys # dumps/ holds full RAM images written by avml (as root). It was monsys:monsys # 0755, which handed every secret in memory to anyone with the agent uid. The # agent only ever writes here via monsys-action memdump; nothing reads it back. install -d -o root -g root -m 0700 /var/lib/monsys/dumps 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 # Group membership: `adm` opens /var/log/syslog + /var/log/auth.log for the # emergency operator; `systemd-journal` opens journalctl. Both read-only. # Without these, a noodconsole session can list directories but every # diagnostic command returns "Permission denied" — exactly the wrong UX # in an emergency. The actual security perimeter is the unprivileged # UID + the narrow sudoers whitelist below, not log-file ACLs. usermod -aG adm,systemd-journal monsys-console || true cat > /var/lib/monsys/console/.bashrc <<'BASHRC' # Monsys Emergency Console — alle invoer wordt geregistreerd # (auditd taggt elke execve onder uid=monsys-console met de session_id) PS1='\[\033[01;31m\][MONSYS-CONSOLE]\[\033[00m\] \u@\h:\w\$ ' export HISTFILE=/var/lib/monsys/console/.bash_history export HISTTIMEFORMAT="%F %T " export HISTCONTROL= shopt -s histappend # `cd` zonder arg landt in /var/log — daar begint een nooddiagnose meestal. cd /var/log 2>/dev/null || cd "$HOME" cat <<'BANNER' ╔══════════════════════════════════════════════════════════════════════╗ ║ MONSYS NOODCONSOLE ║ ║ Alle invoer wordt geregistreerd (auditd + bash_history) ║ ║ ║ ║ Read access zonder sudo: ║ ║ /var/log/syslog, /var/log/auth.log (via adm group) ║ ║ journalctl, journalctl -u (via systemd-journal grp) ║ ║ ║ ║ Toegestane sudo-acties (NOPASSWD, narrow whitelist): ║ ║ sudo systemctl restart|reload|status ║ ║ sudo systemctl start|stop ║ ║ sudo dmesg (kernel ringbuffer) ║ ║ sudo journalctl -k (kernel journal) ║ ║ sudo ss -tnlp (sockets met process-info) ║ ║ ║ ║ Whitelist units: monsys-hub monsys-agent monsys-dashboard ║ ║ caddy nginx postgresql redis-server ollama ║ ║ ║ ║ Voor andere acties: gebruik de Playbook/EAT-flow in de hub UI. ║ ║ Session TTL: 15 min. ║ ╚══════════════════════════════════════════════════════════════════════╝ BANNER BASHRC chown monsys-console:monsys-console /var/lib/monsys/console/.bashrc echo "→ installing monsys-console-launch wrapper (setuid-root → setpriv-drop)" # The wrapper runs briefly as root (via NOPASSWD sudo from the `monsys` # agent user), validates the session_id format, then setpriv-drops into # monsys-console with --inh-caps=-all so the spawned bash inherits zero # capabilities. Why not setpriv directly from the agent: the `monsys` # user has no CAP_SETUID, and granting it would make the wide agent # sudoers (iptables, monsys-action wrapper) reachable from any shell # escape. The wrapper isolates that path. # # IMPORTANT: do NOT add --restricted to the bash invocation. rbash blocks # `cd`, redirects, `exec`, and paths-with-/ — all of which the operator # needs for emergency diagnosis. The security perimeter is the UID + # capability drop + the narrow sudoers whitelist, NOT rbash. install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/monsys-console-launch <<'LAUNCH' #!/bin/bash set -e SESSION_ID="${1:?session_id required}" if ! [[ "$SESSION_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then echo "monsys-console-launch: invalid session_id" >&2 exit 2 fi if ! id monsys-console >/dev/null 2>&1; then echo "monsys-console-launch: monsys-console user missing — reinstall agent" >&2 exit 3 fi export MONSYS_SESSION_ID="$SESSION_ID" export TERM="${TERM:-xterm-256color}" export HOME="/var/lib/monsys/console" export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" export SHELL="/bin/bash" exec setpriv \ --reuid=monsys-console \ --regid=monsys-console \ --init-groups \ --inh-caps=-all \ -- \ /bin/bash --rcfile /var/lib/monsys/console/.bashrc -i LAUNCH # Recent sudo dropped the `requiretty` setting and now hard-fails parsing # with "unknown setting: 'requiretty'". Probe once instead of matching on a # version string. Old RHEL/CentOS enable requiretty in /etc/sudoers, so the # line has to stay on hosts that still understand it. SUDOERS_REQUIRETTY=0 _rt_probe="$(mktemp)" printf 'Defaults !requiretty\n' >"$_rt_probe" chmod 0440 "$_rt_probe" if visudo -cf "$_rt_probe" >/dev/null 2>&1; then SUDOERS_REQUIRETTY=1; fi rm -f "$_rt_probe" [[ "$SUDOERS_REQUIRETTY" == "1" ]] || echo " (sudo has no requiretty setting, omitting those lines)" # install_sudoers — body on stdin. Validates in a temp file and only # then moves it into /etc/sudoers.d. Writing to the destination first and # running visudo afterwards leaves an unparsable fragment on disk, and sudo # refuses to run at all when any file under sudoers.d fails to parse: that # locks the operator out of the box we are supposed to be installing on. install_sudoers() { local dest="$1" tmp tmp="$(mktemp)" if [[ "$SUDOERS_REQUIRETTY" == "1" ]]; then cat >"$tmp" else grep -v 'requiretty' >"$tmp" || true fi chmod 0440 "$tmp" chown root:root "$tmp" if ! visudo -cf "$tmp"; then rm -f "$tmp" echo "refusing to install unparsable sudoers file: $dest" >&2 exit 1 fi install -m 0440 -o root -g root "$tmp" "$dest" rm -f "$tmp" } echo "→ installing /etc/sudoers.d/monsys-console + /etc/sudoers.d/monsys-console-ops" # /etc/sudoers.d/monsys-console — only the agent (`monsys`) can launch the # wrapper. No other sudo path here. install_sudoers /etc/sudoers.d/monsys-console <<'SUDOERS_LAUNCH' # Allow the monsys agent user to launch an emergency console session # without password. The wrapper validates session_id format + drops # privileges via setpriv. No other elevation is granted here. monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-console-launch * Defaults!/usr/local/sbin/monsys-console-launch !requiretty SUDOERS_LAUNCH # /etc/sudoers.d/monsys-console-ops — narrow whitelist for the operator # INSIDE the console session. Exact unit names only, no wildcards on the # unit-name slot. Wildcard on systemctl status/is-active/is-failed is # safe because those commands cannot mutate state. install_sudoers /etc/sudoers.d/monsys-console-ops <<'SUDOERS_OPS' Defaults:monsys-console !requiretty Defaults:monsys-console env_keep += "MONSYS_SESSION_ID" # Service control — exact unit names only. monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart monsys-hub monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart monsys-agent monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart monsys-dashboard monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart caddy monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart nginx monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart postgresql monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart redis-server monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart ollama monsys-console ALL=(root) NOPASSWD: /bin/systemctl reload caddy monsys-console ALL=(root) NOPASSWD: /bin/systemctl reload nginx monsys-console ALL=(root) NOPASSWD: /bin/systemctl reload postgresql monsys-console ALL=(root) NOPASSWD: /bin/systemctl start monsys-hub monsys-console ALL=(root) NOPASSWD: /bin/systemctl start monsys-agent monsys-console ALL=(root) NOPASSWD: /bin/systemctl start monsys-dashboard monsys-console ALL=(root) NOPASSWD: /bin/systemctl start caddy monsys-console ALL=(root) NOPASSWD: /bin/systemctl start nginx monsys-console ALL=(root) NOPASSWD: /bin/systemctl start postgresql monsys-console ALL=(root) NOPASSWD: /bin/systemctl start redis-server monsys-console ALL=(root) NOPASSWD: /bin/systemctl start ollama monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop monsys-hub monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop monsys-dashboard monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop caddy monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop nginx monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop redis-server monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop ollama # Deliberately NO `stop monsys-agent` (would orphan the agent box) and # NO `stop postgresql` (locks hub out of state). monsys-console ALL=(root) NOPASSWD: /bin/systemctl status * monsys-console ALL=(root) NOPASSWD: /bin/systemctl is-active * monsys-console ALL=(root) NOPASSWD: /bin/systemctl is-failed * monsys-console ALL=(root) NOPASSWD: /bin/systemctl is-enabled * # Kernel-protected diagnostics. monsys-console ALL=(root) NOPASSWD: /usr/bin/dmesg monsys-console ALL=(root) NOPASSWD: /usr/bin/dmesg -T monsys-console ALL=(root) NOPASSWD: /usr/bin/dmesg --since * monsys-console ALL=(root) NOPASSWD: /usr/bin/dmesg -wH monsys-console ALL=(root) NOPASSWD: /usr/bin/journalctl -k monsys-console ALL=(root) NOPASSWD: /usr/bin/journalctl -k -f monsys-console ALL=(root) NOPASSWD: /usr/bin/journalctl -k --since * # Networking with process-info (needs root for the -p flag). monsys-console ALL=(root) NOPASSWD: /usr/bin/ss -tnp monsys-console ALL=(root) NOPASSWD: /usr/bin/ss -tnlp monsys-console ALL=(root) NOPASSWD: /usr/bin/ss -unp monsys-console ALL=(root) NOPASSWD: /usr/bin/ss -tunp monsys-console ALL=(root) NOPASSWD: /usr/bin/lsof -i monsys-console ALL=(root) NOPASSWD: /usr/bin/lsof -nP -i monsys-console ALL=(root) NOPASSWD: /usr/bin/lsof -iTCP monsys-console ALL=(root) NOPASSWD: /usr/bin/lsof -iUDP SUDOERS_OPS # install_sudoers already validated each fragment before it landed in # /etc/sudoers.d, so there is nothing left to sanity-check here. 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. # # sudo-rs (the default sudo on Ubuntu 25.10+) rejects a `*` that sits inside # an argument, and rejects any `*` that is not the whole final argument. So # the hub-egress rules carry the literal host instead of a pattern. This must # stay byte-identical to what extract_hub_host() in agent/src/emergency/ # actions.rs passes to iptables, otherwise IsolateNetwork loses hub egress # and the host goes permanently offline. HUB_HOST="${MONSYS_HUB_URL#http://}" HUB_HOST="${HUB_HOST#https://}" HUB_HOST="${HUB_HOST%%/*}" HUB_HOST="${HUB_HOST##*@}" case "$HUB_HOST" in *\]*) HUB_HOST="${HUB_HOST%%\]*}"; HUB_HOST="${HUB_HOST#\[}" ;; # IPv6 literal *) HUB_HOST="${HUB_HOST%%:*}" ;; esac if [[ -z "$HUB_HOST" ]]; then echo "cannot derive hub host from MONSYS_HUB_URL=$MONSYS_HUB_URL" >&2 exit 1 fi # The heredoc stays quoted so that backticks and `$` in the comments below are # not command-substituted at install time; the one variable part is a # placeholder rewritten by sed. sed "s|@HUB_HOST@|${HUB_HOST}|g" <<'SUDOERS' | install_sudoers /etc/sudoers.d/monsys 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 @HUB_HOST@ -p tcp --dport 443 -j ACCEPT monsys ALL=(ALL) NOPASSWD: /usr/sbin/iptables -A INPUT -s @HUB_HOST@ -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 @HUB_HOST@ -p tcp --dport 443 -j ACCEPT monsys ALL=(ALL) NOPASSWD: /usr/sbin/ip6tables -A INPUT -s @HUB_HOST@ -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 * # Memory dump. Was `avml /var/lib/monsys/dumps/*`, but sudo-rs rejects a glob # inside an argument, and that pattern also let the monsys uid pick the output # path. The wrapper hardcodes the directory and validates the basename. monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-action memdump * # 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 * # Console self-heal — runs on agent startup when /etc/sudoers.d/monsys-console-ops # is missing or wrapper has drifted. Re-installs wrapper + sudoers + groups. # No args — the wrapper hardcodes the file contents. monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-update fixup-console # 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 * # S1.10 — S.M.A.R.T. disk health. Wrapper only ever runs smartctl in # read-only mode; `read` validates the device path against a strict # /dev/ regex before exec'ing. monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-smart scan monsys ALL=(root) NOPASSWD: /usr/local/sbin/monsys-smart read * 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" ;; memdump) # Takes a BASENAME only. The directory is hardcoded here so the monsys # uid cannot steer avml's output at an arbitrary path (avml runs as root # and truncates whatever it is given). name="${1:-}" [[ "$name" =~ ^dump-[0-9]{1,20}\.lime$ ]] || die "bad dump name: $name" need /usr/bin/avml install -d -o root -g root -m 0700 /var/lib/monsys/dumps exec /usr/bin/avml "/var/lib/monsys/dumps/$name" ;; 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-smart wrapper" # S1.10 — read-only smartctl wrapper. The agent (monsys uid) calls this via # sudo to read disk health; smartctl needs raw device access (root). No # write/set flags ever pass through here. Degrades cleanly: exit 64 when # smartmontools is not installed, agent then reports an empty SMART list. install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/monsys-smart <<'SMARTWRAP' #!/usr/bin/env bash set -euo pipefail die() { echo "monsys-smart: $*" >&2; exit 64; } SMARTCTL="$(command -v smartctl || true)" [[ -n "$SMARTCTL" ]] || die "smartctl not installed" verb="${1:-}" case "$verb" in scan) exec "$SMARTCTL" --scan-open -j ;; read) dev="${2:-}" [[ "$dev" =~ ^/dev/(sd[a-z]{1,3}|hd[a-z]|vd[a-z]|xvd[a-z]|nvme[0-9]{1,2}n[0-9]{1,2}|mmcblk[0-9]{1,2})$ ]] \ || die "device not allowed: $dev" # smartctl exit code is a bitmask: failing-disk bits (3+) still come # with valid JSON on stdout. Pass output through, mask the exit code. "$SMARTCTL" -a -j -- "$dev" || true ;; *) die "usage: monsys-smart scan | read /dev/" ;; esac SMARTWRAP 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 1 ]] || die "usage: monsys-update apply | apply-hub | fixup-console" verb="$1"; shift # fixup-console — re-applies the emergency-console install state (wrapper + # sudoers + groups + bashrc) without re-running the entire installer. # Idempotent. Invoked by the agent on startup when it detects drift # (e.g. /etc/sudoers.d/monsys-console-ops is missing). This is the path # by which existing agents pick up a console-layer fix without an SSH # round-trip; the file-writes here MUST stay byte-identical to the # equivalent heredocs at the top of install.sh. if [[ "$verb" == "fixup-console" ]]; then install -d -o monsys-console -g monsys-console -m 0750 /var/lib/monsys/console 2>/dev/null || true usermod -aG adm,systemd-journal monsys-console 2>/dev/null || true cat > /var/lib/monsys/console/.bashrc <<'BASHRC_FIXUP' # 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 " export HISTCONTROL= shopt -s histappend cd /var/log 2>/dev/null || cd "$HOME" cat <<'BANNER' ╔══════════════════════════════════════════════════════════════════════╗ ║ MONSYS NOODCONSOLE ║ ║ Alle invoer wordt geregistreerd (auditd + bash_history) ║ ║ ║ ║ Read access zonder sudo: ║ ║ /var/log/syslog, /var/log/auth.log (via adm group) ║ ║ journalctl, journalctl -u (via systemd-journal grp) ║ ║ ║ ║ Toegestane sudo-acties (NOPASSWD, narrow whitelist): ║ ║ sudo systemctl restart|reload|status ║ ║ sudo systemctl start|stop ║ ║ sudo dmesg (kernel ringbuffer) ║ ║ sudo journalctl -k (kernel journal) ║ ║ sudo ss -tnlp (sockets met process-info) ║ ║ ║ ║ Whitelist units: monsys-hub monsys-agent monsys-dashboard ║ ║ caddy nginx postgresql redis-server ollama ║ ║ ║ ║ Voor andere acties: gebruik de Playbook/EAT-flow in de hub UI. ║ ║ Session TTL: 15 min. ║ ╚══════════════════════════════════════════════════════════════════════╝ BANNER BASHRC_FIXUP chown monsys-console:monsys-console /var/lib/monsys/console/.bashrc install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/monsys-console-launch <<'LAUNCH_FIXUP' #!/bin/bash set -e SESSION_ID="${1:?session_id required}" if ! [[ "$SESSION_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then echo "monsys-console-launch: invalid session_id" >&2 exit 2 fi if ! id monsys-console >/dev/null 2>&1; then echo "monsys-console-launch: monsys-console user missing — reinstall agent" >&2 exit 3 fi export MONSYS_SESSION_ID="$SESSION_ID" export TERM="${TERM:-xterm-256color}" export HOME="/var/lib/monsys/console" export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" export SHELL="/bin/bash" exec setpriv \ --reuid=monsys-console \ --regid=monsys-console \ --init-groups \ --inh-caps=-all \ -- \ /bin/bash --rcfile /var/lib/monsys/console/.bashrc -i LAUNCH_FIXUP # Same two hazards as the installer: recent sudo rejects `requiretty`, # and an unparsable fragment under sudoers.d makes sudo refuse to run # at all. Stage in a temp file, validate, only then move into place. ops_tmp="$(mktemp)" cat > "$ops_tmp" <<'OPS_FIXUP' Defaults:monsys-console !requiretty Defaults:monsys-console env_keep += "MONSYS_SESSION_ID" monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart monsys-hub monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart monsys-agent monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart monsys-dashboard monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart caddy monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart nginx monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart postgresql monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart redis-server monsys-console ALL=(root) NOPASSWD: /bin/systemctl restart ollama monsys-console ALL=(root) NOPASSWD: /bin/systemctl reload caddy monsys-console ALL=(root) NOPASSWD: /bin/systemctl reload nginx monsys-console ALL=(root) NOPASSWD: /bin/systemctl reload postgresql monsys-console ALL=(root) NOPASSWD: /bin/systemctl start monsys-hub monsys-console ALL=(root) NOPASSWD: /bin/systemctl start monsys-agent monsys-console ALL=(root) NOPASSWD: /bin/systemctl start monsys-dashboard monsys-console ALL=(root) NOPASSWD: /bin/systemctl start caddy monsys-console ALL=(root) NOPASSWD: /bin/systemctl start nginx monsys-console ALL=(root) NOPASSWD: /bin/systemctl start postgresql monsys-console ALL=(root) NOPASSWD: /bin/systemctl start redis-server monsys-console ALL=(root) NOPASSWD: /bin/systemctl start ollama monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop monsys-hub monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop monsys-dashboard monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop caddy monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop nginx monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop redis-server monsys-console ALL=(root) NOPASSWD: /bin/systemctl stop ollama monsys-console ALL=(root) NOPASSWD: /bin/systemctl status * monsys-console ALL=(root) NOPASSWD: /bin/systemctl is-active * monsys-console ALL=(root) NOPASSWD: /bin/systemctl is-failed * monsys-console ALL=(root) NOPASSWD: /bin/systemctl is-enabled * monsys-console ALL=(root) NOPASSWD: /usr/bin/dmesg monsys-console ALL=(root) NOPASSWD: /usr/bin/dmesg -T monsys-console ALL=(root) NOPASSWD: /usr/bin/dmesg --since * monsys-console ALL=(root) NOPASSWD: /usr/bin/dmesg -wH monsys-console ALL=(root) NOPASSWD: /usr/bin/journalctl -k monsys-console ALL=(root) NOPASSWD: /usr/bin/journalctl -k -f monsys-console ALL=(root) NOPASSWD: /usr/bin/journalctl -k --since * monsys-console ALL=(root) NOPASSWD: /usr/bin/ss -tnp monsys-console ALL=(root) NOPASSWD: /usr/bin/ss -tnlp monsys-console ALL=(root) NOPASSWD: /usr/bin/ss -unp monsys-console ALL=(root) NOPASSWD: /usr/bin/ss -tunp monsys-console ALL=(root) NOPASSWD: /usr/bin/lsof -i monsys-console ALL=(root) NOPASSWD: /usr/bin/lsof -nP -i monsys-console ALL=(root) NOPASSWD: /usr/bin/lsof -iTCP monsys-console ALL=(root) NOPASSWD: /usr/bin/lsof -iUDP OPS_FIXUP if ! visudo -cf "$ops_tmp" >/dev/null 2>&1; then grep -v 'requiretty' "$ops_tmp" > "$ops_tmp.rt" && mv -f "$ops_tmp.rt" "$ops_tmp" fi chmod 0440 "$ops_tmp"; chown root:root "$ops_tmp" visudo -cf "$ops_tmp" >&2 || die "generated sudoers does not parse, leaving existing file untouched" install -m 0440 -o root -g root "$ops_tmp" /etc/sudoers.d/monsys-console-ops rm -f "$ops_tmp" echo "monsys-update: fixup-console OK" exit 0 fi 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 [[ $# -ge 1 ]] || die "usage: monsys-update apply | apply-hub | fixup-console" 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"