#!/usr/bin/env bash set -uo pipefail VERSION="1.0.0" PASS_COUNT=0 WARN_COUNT=0 FAIL_COUNT=0 usage() { printf '%s\n' \ "OpenClaw VPS Readiness Check ${VERSION}" \ "" \ "Usage:" \ " sudo bash openclaw-vps-readiness-check.sh \\" \ " --runtime-user \\" \ " --openclaw-bin \\" \ " --env-file \\" \ " [--state-dir ]" \ "" \ "Run only after reading the script and only on a fresh or throwaway Ubuntu" \ "24.04 host. The checker reads local configuration and service state. It" \ "does not install packages, change configuration, make outbound requests," \ "or print hostnames, IP addresses, environment values, or secret values." \ "" \ "Exit status: 0 when no automated check fails; 1 when one or more fail;" \ "64 for invalid usage." } pass() { PASS_COUNT=$((PASS_COUNT + 1)) printf -- '- PASS — %s\n' "$1" } warn() { WARN_COUNT=$((WARN_COUNT + 1)) printf -- '- WARN — %s\n' "$1" } fail() { FAIL_COUNT=$((FAIL_COUNT + 1)) printf -- '- FAIL — %s\n' "$1" } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then usage exit 0 fi RUNTIME_USER="" OPENCLAW_BIN="" ENV_FILE="" STATE_DIR="" while [[ "$#" -gt 0 ]]; do case "$1" in --runtime-user) [[ "$#" -ge 2 ]] || { usage >&2; exit 64; } RUNTIME_USER="$2" shift 2 ;; --openclaw-bin) [[ "$#" -ge 2 ]] || { usage >&2; exit 64; } OPENCLAW_BIN="$2" shift 2 ;; --env-file) [[ "$#" -ge 2 ]] || { usage >&2; exit 64; } ENV_FILE="$2" shift 2 ;; --state-dir) [[ "$#" -ge 2 ]] || { usage >&2; exit 64; } STATE_DIR="$2" shift 2 ;; *) printf 'ERROR: Unknown argument.\n' >&2 usage >&2 exit 64 ;; esac done if [[ -z "$RUNTIME_USER" || -z "$OPENCLAW_BIN" || -z "$ENV_FILE" ]]; then usage >&2 exit 64 fi if [[ ! "$RUNTIME_USER" =~ ^[a-z_][a-z0-9_-]*$ ]]; then printf 'ERROR: Runtime user must be a simple local account name.\n' >&2 exit 64 fi for CHECK_PATH in "$OPENCLAW_BIN" "$ENV_FILE"; do if [[ "$CHECK_PATH" != /* || "$CHECK_PATH" == *$'\n'* ]]; then printf 'ERROR: Binary, environment, and state paths must be absolute single-line paths.\n' >&2 exit 64 fi done if [[ -n "$STATE_DIR" && ( "$STATE_DIR" != /* || "$STATE_DIR" == *$'\n'* ) ]]; then printf 'ERROR: Binary, environment, and state paths must be absolute single-line paths.\n' >&2 exit 64 fi if [[ "$(id -u)" -ne 0 ]]; then printf 'ERROR: Run with sudo after inspecting the script; root is required for read-only host checks.\n' >&2 exit 64 fi PASSWD_RECORD="$(getent passwd "$RUNTIME_USER" 2>/dev/null || true)" if [[ -z "$PASSWD_RECORD" ]]; then printf 'ERROR: Runtime user does not exist.\n' >&2 exit 64 fi RUNTIME_UID="$(printf '%s\n' "$PASSWD_RECORD" | awk -F: '{print $3}')" RUNTIME_HOME="$(printf '%s\n' "$PASSWD_RECORD" | awk -F: '{print $6}')" if [[ -z "$RUNTIME_UID" || -z "$RUNTIME_HOME" || "$RUNTIME_HOME" != /* ]]; then printf 'ERROR: Runtime account has an invalid local passwd record.\n' >&2 exit 64 fi if [[ -z "$STATE_DIR" ]]; then STATE_DIR="${RUNTIME_HOME}/.openclaw" fi OPENCLAW_DIR="${OPENCLAW_BIN%/*}" SAFE_PATH="${OPENCLAW_DIR}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" NETWORK_ISOLATION_AVAILABLE="false" if command -v unshare >/dev/null 2>&1; then NETWORK_ISOLATION_AVAILABLE="true" fi run_as_runtime() { [[ "$NETWORK_ISOLATION_AVAILABLE" == "true" ]] || return 125 unshare --net -- \ runuser -u "$RUNTIME_USER" -- \ env -i \ HOME="$RUNTIME_HOME" \ USER="$RUNTIME_USER" \ LOGNAME="$RUNTIME_USER" \ PATH="$SAFE_PATH" \ NO_COLOR=1 \ OPENCLAW_STATE_DIR="$STATE_DIR" \ OPENCLAW_CONFIG_PATH="$STATE_DIR/openclaw.json" \ "$@" } mode_blocks_other_users() { local mode="$1" local numeric_mode [[ "$mode" =~ ^[0-7]+$ ]] || return 1 numeric_mode=$((8#$mode)) (( (numeric_mode & 077) == 0 )) } printf '# OpenClaw VPS hardening acceptance evidence\n\n' printf -- '- Checked at: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf -- '- Checker: %s\n' "$VERSION" printf -- '- Target identity: intentionally omitted\n' printf -- '- Hostname and IP addresses: intentionally omitted\n' printf -- '- Outbound network requests: none\n' printf -- '- Configuration changes: none\n\n' printf '## Platform and runtime identity\n\n' if [[ "$(uname -s 2>/dev/null || true)" == "Linux" ]]; then pass "Linux host detected." else fail "This acceptance contract is for a Linux VPS." fi OS_ID="" OS_VERSION="" if [[ -r /etc/os-release ]]; then OS_ID="$(sed -n 's/^ID=//p' /etc/os-release | head -1 | tr -d '"')" OS_VERSION="$(sed -n 's/^VERSION_ID=//p' /etc/os-release | head -1 | tr -d '"')" fi if [[ "$OS_ID" == "ubuntu" && "$OS_VERSION" == "24.04" ]]; then pass "Ubuntu 24.04 detected." else fail "Expected Ubuntu 24.04; no release value is printed." fi if [[ "$RUNTIME_UID" != "0" ]]; then pass "OpenClaw runtime account is non-root." else fail "OpenClaw runtime account must not be root." fi if [[ "$NETWORK_ISOLATION_AVAILABLE" == "true" ]]; then pass "OpenClaw read-only CLI checks run inside a network-isolated process namespace." else fail "The unshare utility is required to prove OpenClaw CLI checks cannot make outbound requests." fi RUNTIME_GROUPS="$(id -nG "$RUNTIME_USER" 2>/dev/null || true)" if printf '%s\n' "$RUNTIME_GROUPS" | tr ' ' '\n' | grep -Eq '^(sudo|admin|wheel)$'; then fail "Runtime account belongs to an administrative group." else pass "Runtime account is not in sudo, admin, or wheel." fi if [[ -x "$OPENCLAW_BIN" && ! -L "$OPENCLAW_BIN" ]]; then pass "Explicit OpenClaw launcher is an executable regular path." elif [[ -x "$OPENCLAW_BIN" && -L "$OPENCLAW_BIN" ]]; then pass "Explicit OpenClaw launcher is an executable symlink." else fail "Explicit OpenClaw launcher is not executable." fi RESOLVED_OPENCLAW_BIN="$(readlink -f "$OPENCLAW_BIN" 2>/dev/null || true)" if [[ -n "$RESOLVED_OPENCLAW_BIN" && "$RESOLVED_OPENCLAW_BIN" == "$RUNTIME_HOME/"* ]]; then pass "OpenClaw resolves inside the dedicated user's home, consistent with a user-local install." else fail "OpenClaw does not resolve inside the dedicated user's home." fi if run_as_runtime "$OPENCLAW_BIN" --version >/dev/null 2>&1; then pass "OpenClaw returns a version as the dedicated runtime user." else fail "OpenClaw version check failed under the dedicated runtime user." fi printf '\n## OpenClaw state and configuration\n\n' if [[ -d "$STATE_DIR" && ! -L "$STATE_DIR" ]]; then STATE_MODE="$(stat -c '%a' "$STATE_DIR" 2>/dev/null || true)" STATE_OWNER="$(stat -c '%u' "$STATE_DIR" 2>/dev/null || true)" if [[ "$STATE_OWNER" == "$RUNTIME_UID" ]] && mode_blocks_other_users "$STATE_MODE"; then pass "OpenClaw state directory is owned by the runtime account and inaccessible to group/other users." else fail "OpenClaw state directory ownership or permissions are too broad." fi else fail "OpenClaw state directory is missing or is a symlink." fi CONFIG_FILE="${STATE_DIR}/openclaw.json" if [[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]]; then CONFIG_MODE="$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || true)" CONFIG_OWNER="$(stat -c '%u' "$CONFIG_FILE" 2>/dev/null || true)" if [[ "$CONFIG_OWNER" == "$RUNTIME_UID" ]] && mode_blocks_other_users "$CONFIG_MODE"; then pass "OpenClaw configuration file is owner-only." else fail "OpenClaw configuration file ownership or permissions are too broad." fi else fail "Expected OpenClaw configuration file is missing or is a symlink." fi if run_as_runtime "$OPENCLAW_BIN" config validate --json >/dev/null 2>&1; then pass "OpenClaw configuration validation succeeds." else fail "OpenClaw configuration validation failed." fi GATEWAY_BIND="$(run_as_runtime "$OPENCLAW_BIN" config get gateway.bind 2>/dev/null || true)" if [[ "$GATEWAY_BIND" == "loopback" ]]; then pass "OpenClaw Gateway bind mode is loopback." else fail "OpenClaw Gateway bind mode is not confirmed as loopback." fi GATEWAY_AUTH="$(run_as_runtime "$OPENCLAW_BIN" config get gateway.auth.mode 2>/dev/null || true)" case "$GATEWAY_AUTH" in token|password) pass "OpenClaw Gateway uses shared-secret authentication." ;; trusted-proxy) warn "Gateway uses trusted-proxy authentication; validate proxy identity and direct-path blocking manually." ;; *) fail "OpenClaw Gateway authentication is missing, disabled, or unreadable." ;; esac printf '\n## Service supervision and secret placement\n\n' shopt -s nullglob UNIT_CANDIDATES=( "$RUNTIME_HOME"/.config/systemd/user/openclaw-gateway*.service /etc/systemd/system/openclaw-gateway*.service /usr/lib/systemd/system/openclaw-gateway*.service /lib/systemd/system/openclaw-gateway*.service ) shopt -u nullglob UNIT_FILE="" if [[ "${#UNIT_CANDIDATES[@]}" -eq 1 ]]; then UNIT_FILE="${UNIT_CANDIDATES[0]}" elif [[ "${#UNIT_CANDIDATES[@]}" -eq 0 ]]; then fail "No OpenClaw systemd unit was found." else fail "Multiple OpenClaw systemd units were found; select and verify the intended unit manually." fi UNIT_CONTENT="" UNIT_NAME="" UNIT_SCOPE="" if [[ -n "$UNIT_FILE" && -f "$UNIT_FILE" ]]; then UNIT_NAME="${UNIT_FILE##*/}" UNIT_CONTENT="$(sed -n '1,500p' "$UNIT_FILE" 2>/dev/null || true)" shopt -s nullglob DROP_INS=("$UNIT_FILE".d/*.conf) shopt -u nullglob for DROP_IN in "${DROP_INS[@]}"; do UNIT_CONTENT+=$'\n' UNIT_CONTENT+="$(sed -n '1,300p' "$DROP_IN" 2>/dev/null || true)" done if [[ "$UNIT_FILE" == "$RUNTIME_HOME/.config/systemd/user/"* ]]; then UNIT_SCOPE="user" pass "A dedicated-user systemd unit was found." else UNIT_SCOPE="system" EFFECTIVE_UNIT_USER="$( printf '%s\n' "$UNIT_CONTENT" | sed -n 's/^[[:space:]]*User[[:space:]]*=[[:space:]]*//p' | tail -1 )" if [[ "$EFFECTIVE_UNIT_USER" == "$RUNTIME_USER" ]]; then pass "System service explicitly runs as the dedicated runtime account." else fail "System service does not explicitly run as the dedicated runtime account." fi fi EFFECTIVE_RESTART="$( printf '%s\n' "$UNIT_CONTENT" | sed -n 's/^[[:space:]]*Restart[[:space:]]*=[[:space:]]*//p' | tail -1 )" if [[ "$EFFECTIVE_RESTART" == "always" ]]; then pass "Systemd restart policy is always." else fail "Systemd restart policy is not confirmed as always." fi if printf '%s\n' "$UNIT_CONTENT" | grep -Eiq '^[[:space:]]*Environment[[:space:]]*=.*(TOKEN|PASSWORD|SECRET|API_?KEY|PRIVATE_?KEY|CREDENTIAL)[[:space:]]*='; then fail "A secret-shaped value appears inline in the systemd unit." elif printf '%s\n' "$UNIT_CONTENT" | grep -Eiq '^[[:space:]]*ExecStart[[:space:]]*=.*(--token|--password|--api[-_]?key)([=[:space:]]|$)'; then fail "A secret-shaped command-line flag appears in ExecStart." else pass "No secret-shaped inline Environment or ExecStart value was detected." fi if printf '%s\n' "$UNIT_CONTENT" | grep -Fq "$ENV_FILE"; then pass "Systemd unit references the supplied environment file." else fail "Systemd unit does not reference the supplied environment file path." fi if [[ "$UNIT_SCOPE" == "system" ]]; then if systemctl is-enabled "$UNIT_NAME" >/dev/null 2>&1; then pass "OpenClaw system service is enabled." else fail "OpenClaw system service is not enabled." fi if systemctl is-active "$UNIT_NAME" >/dev/null 2>&1; then pass "OpenClaw system service is active." else fail "OpenClaw system service is not active." fi elif [[ "$UNIT_SCOPE" == "user" ]]; then LINGER="$(loginctl show-user "$RUNTIME_USER" -p Linger --value 2>/dev/null || true)" if [[ "$LINGER" == "yes" ]]; then pass "Systemd user lingering is enabled for always-on operation." else fail "Systemd user lingering is not enabled." fi RUNTIME_DIR="/run/user/${RUNTIME_UID}" if runuser -u "$RUNTIME_USER" -- env -i \ HOME="$RUNTIME_HOME" \ USER="$RUNTIME_USER" \ LOGNAME="$RUNTIME_USER" \ PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ XDG_RUNTIME_DIR="$RUNTIME_DIR" \ systemctl --user is-enabled "$UNIT_NAME" >/dev/null 2>&1; then pass "OpenClaw user service is enabled." else fail "OpenClaw user service is not enabled or could not be inspected." fi if runuser -u "$RUNTIME_USER" -- env -i \ HOME="$RUNTIME_HOME" \ USER="$RUNTIME_USER" \ LOGNAME="$RUNTIME_USER" \ PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ XDG_RUNTIME_DIR="$RUNTIME_DIR" \ systemctl --user is-active "$UNIT_NAME" >/dev/null 2>&1; then pass "OpenClaw user service is active." else fail "OpenClaw user service is not active or could not be inspected." fi fi fi if [[ -f "$ENV_FILE" && ! -L "$ENV_FILE" ]]; then ENV_MODE="$(stat -c '%a' "$ENV_FILE" 2>/dev/null || true)" ENV_OWNER="$(stat -c '%u' "$ENV_FILE" 2>/dev/null || true)" if [[ "$ENV_MODE" == "600" && "$ENV_OWNER" == "$RUNTIME_UID" ]]; then pass "Supplied environment file is a runtime-owned regular file with mode 0600." else fail "Supplied environment file must be runtime-owned and mode 0600." fi else fail "Supplied environment file is missing or is a symlink." fi printf '\n## SSH and host firewall\n\n' SSHD_BIN="$(command -v sshd 2>/dev/null || true)" if [[ -z "$SSHD_BIN" && -x /usr/sbin/sshd ]]; then SSHD_BIN="/usr/sbin/sshd" fi SSHD_EFFECTIVE="" if [[ -n "$SSHD_BIN" ]]; then SSHD_EFFECTIVE="$("$SSHD_BIN" -T 2>/dev/null || true)" fi if [[ -n "$SSHD_EFFECTIVE" ]]; then if printf '%s\n' "$SSHD_EFFECTIVE" | grep -Eqi '^pubkeyauthentication yes$' && printf '%s\n' "$SSHD_EFFECTIVE" | grep -Eqi '^passwordauthentication no$' && printf '%s\n' "$SSHD_EFFECTIVE" | grep -Eqi '^kbdinteractiveauthentication no$'; then pass "Effective SSH configuration is key-only." else fail "Effective SSH configuration is not confirmed as key-only." fi if printf '%s\n' "$SSHD_EFFECTIVE" | grep -Eqi '^permitrootlogin no$'; then pass "Effective SSH configuration disables root login." else fail "Effective SSH configuration does not fully disable root login." fi if printf '%s\n' "$SSHD_EFFECTIVE" | grep -Eqi '^port 22$'; then pass "Effective SSH port is 22." else fail "Effective SSH port is not confirmed as 22." fi else warn "Could not render effective sshd configuration; verify key-only access, root-login denial, and port 22 manually." fi UFW_STATUS="$(ufw status verbose 2>/dev/null || true)" if printf '%s\n' "$UFW_STATUS" | grep -Eqi '^Status:[[:space:]]+active$'; then pass "UFW is active." else fail "UFW is not active or could not be inspected." fi if printf '%s\n' "$UFW_STATUS" | grep -Eqi '^Default:.*deny \(incoming\)'; then pass "UFW default incoming policy is deny." else fail "UFW default incoming policy is not confirmed as deny." fi if printf '%s\n' "$UFW_STATUS" | grep -Eqi '^(22(/tcp)?|OpenSSH)[[:space:]]+ALLOW[[:space:]]+IN'; then pass "UFW allows inbound SSH on port 22." else fail "UFW does not show an inbound SSH allowance on port 22." fi UNEXPECTED_UFW_RULES="$( printf '%s\n' "$UFW_STATUS" | awk ' /ALLOW[[:space:]]+IN/ { if ($1 !~ /^22(\/tcp)?$/ && $1 != "OpenSSH") { count += 1 } } END { print count + 0 } ' )" if [[ "$UNEXPECTED_UFW_RULES" == "0" ]]; then pass "No additional UFW ALLOW IN rule was detected." else fail "One or more additional UFW ALLOW IN rules exist; endpoints are intentionally omitted." fi if systemctl is-active fail2ban >/dev/null 2>&1; then pass "fail2ban service is active." else fail "fail2ban service is not active." fi if command -v fail2ban-client >/dev/null 2>&1 && fail2ban-client status sshd >/dev/null 2>&1; then pass "fail2ban sshd jail is active." else fail "fail2ban sshd jail is not active or could not be inspected." fi if command -v ss >/dev/null 2>&1; then NON_LOOPBACK_TCP="$( ss -H -lnt 2>/dev/null | awk ' $1 == "LISTEN" { endpoint = $4 port = endpoint sub(/^.*:/, "", port) if (port == "22") { next } if (endpoint ~ /^127\./ || endpoint ~ /^\[::1\]:/ || endpoint ~ /^::1:/) { next } count += 1 } END { print count + 0 } ' )" if [[ "$NON_LOOPBACK_TCP" == "0" ]]; then pass "No non-loopback TCP listener other than port 22 was detected." else fail "One or more non-loopback TCP listeners other than port 22 exist; endpoints are intentionally omitted." fi else warn "The ss utility is unavailable; verify listener exposure manually." fi printf '\n## Monitoring\n\n' if systemctl is-active crowdsec >/dev/null 2>&1; then pass "CrowdSec service is active." else fail "CrowdSec service is not active." fi if systemctl is-active crowdsec-firewall-bouncer >/dev/null 2>&1 || systemctl is-active crowdsec-firewall-bouncer.service >/dev/null 2>&1; then pass "CrowdSec firewall bouncer is active." else fail "CrowdSec firewall bouncer is not active." fi if systemctl is-active netdata >/dev/null 2>&1; then pass "Netdata service is active." else fail "Netdata service is not active." fi if command -v ss >/dev/null 2>&1; then NETDATA_LISTENERS="$( ss -H -lnt 2>/dev/null | awk ' $1 == "LISTEN" { endpoint = $4 port = endpoint sub(/^.*:/, "", port) if (port == "19999") { total += 1 if (endpoint !~ /^127\./ && endpoint !~ /^\[::1\]:/ && endpoint !~ /^::1:/) { exposed += 1 } } } END { print total + 0, exposed + 0 } ' )" NETDATA_TOTAL="${NETDATA_LISTENERS%% *}" NETDATA_EXPOSED="${NETDATA_LISTENERS##* }" if [[ "$NETDATA_TOTAL" -gt 0 && "$NETDATA_EXPOSED" == "0" ]]; then pass "Netdata TCP listener is loopback-only." elif [[ "$NETDATA_EXPOSED" -gt 0 ]]; then fail "Netdata has a non-loopback TCP listener; endpoint is intentionally omitted." else warn "No Netdata TCP listener was found on port 19999; verify the agreed monitoring access path." fi fi printf '\n## Manual acceptance gates\n\n' printf '%s\n' \ '- MANUAL — Verify the VPS provider firewall exposes only TCP 22 from the agreed source range.' \ '- MANUAL — Verify UDP exposure and any provider-side private network separately.' \ '- MANUAL — Record the exact OpenClaw and Node versions plus the pinned install command in the runbook.' \ '- MANUAL — Review shell history without copying it into the report; confirm no secret value appears.' \ '- MANUAL — Run `openclaw doctor`, `openclaw security audit`, `openclaw security audit --deep`, and `openclaw health` as the runtime user.' \ '- MANUAL — Resolve every critical OpenClaw finding; document each intentionally accepted warning by checkId.' \ '- MANUAL — Reboot the throwaway host, verify service recovery, SSH access, Gateway health, CrowdSec, fail2ban, and Netdata, then record rollback steps.' \ '- MANUAL — Keep the report, redacted unit/config excerpts, runbook, and narrated screen recording; never record secrets.' printf '\n## Result\n\n' printf -- '- Automated passes: %s\n' "$PASS_COUNT" printf -- '- Automated warnings: %s\n' "$WARN_COUNT" printf -- '- Automated failures: %s\n' "$FAIL_COUNT" if [[ "$FAIL_COUNT" -eq 0 ]]; then printf '\n**AUTOMATED RESULT: READY FOR MANUAL ACCEPTANCE.** This is evidence, not a security certification.\n' exit 0 fi printf '\n**AUTOMATED RESULT: NOT READY.** Resolve failed gates on the throwaway host, then run the checker again.\n' exit 1