diff --git a/.github/workflows/bash-syntax.yml b/.github/workflows/bash-syntax.yml index 48637e1..c39ec46 100644 --- a/.github/workflows/bash-syntax.yml +++ b/.github/workflows/bash-syntax.yml @@ -46,3 +46,18 @@ jobs: - name: Test step 8 AI agent catalog run: bash tests/test-step8-agent-catalog.sh + + - name: Test Server Monitor Manager enrollment + run: bash tests/test-monitor-manager-enrollment.sh + + - name: Test Server Monitor Manager Link policies + run: bash tests/test-monitor-manager-links.sh + + - name: Test Server Monitor Manager lifecycle + run: bash tests/test-monitor-manager-lifecycle.sh + + - name: Test Server Monitor Manager extended metrics + run: bash tests/test-monitor-manager-metrics.sh + + - name: Test Server Monitor Manager control layer installer + run: bash tests/test-monitor-manager-control-layer.sh diff --git a/README.md b/README.md index 0b09115..8973e63 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ The wizard intentionally requires all module scripts to be present locally. Use | `ochenstarik-server-panel-warp-6.sh` | 3x-ui panel and local Cloudflare WARP proxy | Optional | | `ochenstarik-server-backup-7.sh` | Protected initial snapshot and selected daily, weekly, or monthly schedules | Optional | | `ochenstarik-server-ai-agents-8.sh` | Installs selected AI agents: Hermes, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT, or Pi Coding Agent | Optional | +| `ochenstarik-server-monitor-manager.sh` | Installs the Server Monitor Manager SSH endpoint, public WireGuard Hub, or outbound-only Node | Optional; Hub needs a public UDP endpoint | | `ochenstarik-server-uninstall.sh` | Removes project-managed settings so installation can start again | Use carefully | Every module can be run independently from the full archive or cloned repository: @@ -63,6 +64,68 @@ sudo ./SCRIPT_NAME.sh Replace `SCRIPT_NAME.sh` with the required filename from the table. +## Server Monitor Manager Hub and Nodes + +The Hub needs a public IPv4 address or domain and an open UDP port (default `51820`). Secondary Nodes establish outbound WireGuard connections and do not need a public IP. + +```bash +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh +chmod 700 ochenstarik-server-monitor-manager.sh +bash -n ochenstarik-server-monitor-manager.sh +sudo ./ochenstarik-server-monitor-manager.sh hub +``` + +Create a separate 10-minute enrollment code for each Node on the Hub, then run the same installer with `node` on the matching server: + +```bash +sudo ochenstarik-smm node-code ai-agent +sudo ochenstarik-smm node-code home +sudo ./ochenstarik-server-monitor-manager.sh node +``` + +Links are directional. The reverse direction requires its own rule: + +```bash +sudo ochenstarik-smm link-connect ai-agent home tcp 22 120 +sudo ochenstarik-smm link-disconnect ai-agent home tcp 22 +sudo ochenstarik-smm nodes +sudo ochenstarik-smm links +``` + +The Node installer creates its private WireGuard key locally and prints an `SMMREQ1` request. In a second terminal run `sudo ochenstarik-smm node-enroll` on the Hub, paste the request at the hidden prompt, and return the resulting `SMMACK1` code to the Node installer. The enrollment code expires after 10 minutes and is consumed by the first successful registration. + +Role-aware maintenance commands create a root-only backup before destructive changes: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh update +sudo ./ochenstarik-server-monitor-manager.sh rollback +sudo ./ochenstarik-server-monitor-manager.sh uninstall-node +sudo ./ochenstarik-server-monitor-manager.sh uninstall-hub +sudo ./ochenstarik-server-monitor-manager.sh uninstall-monitor +``` + +### Experimental persistent control layer + +The first persistent Hub/Agent layer is available as an alpha for three-server testing. It uses self-contained single-file binaries, SQLite on the Hub, one-time enrollment codes, and outbound mTLS heartbeats from Nodes. The existing SSH/WireGuard layer remains installed as a fallback while this alpha is tested. + +Install the Control service after the WireGuard Hub: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh install-control-hub +sudo ./ochenstarik-server-monitor-manager.sh control-code home +sudo ./ochenstarik-server-monitor-manager.sh control-device-code windows-pc +``` + +Copy the resulting `SMMCTL1-...` code and install the Agent on the matching Node: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh install-control-agent +``` + +Create a new code for every Node. The code contains the public Control CA certificate but never its private key. Release archives are verified against their published SHA-256 checksums before installation. The Hub listens on TCP `7443`; allow this port at the hosting provider firewall if an external firewall is used. + +`control-device-code` creates a separate operator identity for the Windows application. Agent certificates cannot list all servers, change Links, or subscribe to the operator event stream. The Control service can invoke only the dedicated `link-connect` and `link-disconnect` policy wrapper through sudo; other Hub administration commands are not granted to it. + ## Installation flow 1. Select the wizard dialog language. diff --git a/docs/readme/README.ru.md b/docs/readme/README.ru.md index 1c76adf..30055d9 100644 --- a/docs/readme/README.ru.md +++ b/docs/readme/README.ru.md @@ -49,10 +49,51 @@ sudo ./ochenstarik-server-install.sh | `ochenstarik-server-panel-warp-6.sh` | Устанавливает 3x-ui и Cloudflare WARP, настраивает и проверяет их порты | Ubuntu 22.04/24.04/26.04 или Debian 12/13 | | `ochenstarik-server-backup-7.sh` | Создаёт неизменяемый первичный снимок и настраивает выбранные расписания резервного копирования | Да; зависимости устанавливаются автоматически | | `ochenstarik-server-ai-agents-8.sh` | Устанавливает выбранных AI-агентов: Hermes, OpenClaw, OpenHands, OpenCode, Aider, AutoGPT или Pi Coding Agent | Да; API-ключи настраиваются отдельно | +| `ochenstarik-server-monitor-manager.sh` | Устанавливает SSH endpoint, главный WireGuard Hub или подключаемый Node для Server Monitor Manager | Да; Hub требуется публичный UDP-адрес | | `ochenstarik-server-uninstall.sh` | Удаляет настройки, службы и данные проекта для повторной установки с начала | Да; запускать только из сохранённой SSH-сессии | Файлы `*.env.example` являются только примерами. Не записывайте настоящие пароли, токены и приватные SSH-ключи в Git. +## Server Monitor Manager: Hub и вторичные серверы + +Главному Hub нужен белый IPv4 или домен и открытый UDP-порт (по умолчанию `51820`). Вторичные Node подключаются исходящим WireGuard-соединением и не требуют белого IP. + +```bash +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-monitor-manager.sh +chmod 700 ochenstarik-server-monitor-manager.sh +bash -n ochenstarik-server-monitor-manager.sh +sudo ./ochenstarik-server-monitor-manager.sh hub +``` + +На Hub создайте отдельный enrollment-код на 10 минут для каждого Node, после чего на соответствующем сервере запустите тот же файл в режиме `node`: + +```bash +sudo ochenstarik-smm node-code ai-agent +sudo ochenstarik-smm node-code home +sudo ./ochenstarik-server-monitor-manager.sh node +``` + +Links являются направленными: + +```bash +sudo ochenstarik-smm link-connect ai-agent home tcp 22 120 +sudo ochenstarik-smm link-disconnect ai-agent home tcp 22 +sudo ochenstarik-smm nodes +sudo ochenstarik-smm links +``` + +Установщик Node создаст приватный WireGuard-ключ локально и покажет запрос `SMMREQ1`. Во втором терминале выполните на Hub `sudo ochenstarik-smm node-enroll`, вставьте запрос по скрытому приглашению и верните полученный код `SMMACK1` в установщик Node. Enrollment-код действует 10 минут и погашается при первой успешной регистрации. + +Команды обслуживания учитывают установленную роль и создают root-only backup перед изменениями: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh update +sudo ./ochenstarik-server-monitor-manager.sh rollback +sudo ./ochenstarik-server-monitor-manager.sh uninstall-node +sudo ./ochenstarik-server-monitor-manager.sh uninstall-hub +sudo ./ochenstarik-server-monitor-manager.sh uninstall-monitor +``` + ## Требования - Ubuntu Server или совместимая Debian-система с `systemd`; diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh new file mode 100644 index 0000000..0e1a377 --- /dev/null +++ b/ochenstarik-server-monitor-manager.sh @@ -0,0 +1,1367 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly APP_NAME="ochenstarik-server-monitor-manager" +readonly MONITOR_USER="ochenstarik-monitor" +readonly MONITOR_HOME="/var/lib/${APP_NAME}" +readonly MONITOR_COMMAND="/usr/local/libexec/ochenstarik-server-monitor" +readonly AUTHORIZED_KEYS="${MONITOR_HOME}/.ssh/authorized_keys" +readonly MESH_DIR="/etc/${APP_NAME}" +readonly NODES_DIR="${MESH_DIR}/nodes" +readonly TOKENS_DIR="${MESH_DIR}/tokens" +readonly HUB_CONFIG="${MESH_DIR}/hub.conf" +readonly HUB_PRIVATE_KEY="${MESH_DIR}/hub.key" +readonly LINKS_FILE="${MESH_DIR}/links" +readonly HUB_HELPER="/usr/local/libexec/ochenstarik-smm-hub" +readonly CONTROL_POLICY_HELPER="/usr/local/libexec/ochenstarik-smm-policy-apply" +readonly HUB_CLI="/usr/local/sbin/ochenstarik-smm" +readonly WG_INTERFACE="smm0" +readonly WG_CONFIG="/etc/wireguard/${WG_INTERFACE}.conf" +readonly MESH_CIDR="10.77.0.0/24" +readonly HUB_ADDRESS="10.77.0.1" +readonly DEFAULT_WG_PORT="51820" +readonly CONTROL_PORT="7443" +readonly CONTROL_USER="ochenstarik-smm-control" +readonly AGENT_USER="ochenstarik-smm-agent" +readonly CONTROL_STATE="${MONITOR_HOME}/control" +readonly AGENT_STATE="${MONITOR_HOME}/agent" +readonly CONTROL_BINARY="/usr/local/lib/${APP_NAME}/control/ochenstarik-smm-control" +readonly AGENT_BINARY="/usr/local/lib/${APP_NAME}/agent/ochenstarik-smm-agent" +readonly CONTROL_ENV="${MESH_DIR}/control.env" +readonly AGENT_ENV="${MESH_DIR}/agent.env" +readonly CONTROL_CA_CERT="${MESH_DIR}/control-ca.crt" +readonly CONTROL_SERVICE="/etc/systemd/system/ochenstarik-smm-control.service" +readonly AGENT_SERVICE="/etc/systemd/system/ochenstarik-smm-agent.service" +readonly SMM_RELEASE_VERSION="${SMM_RELEASE_VERSION:-v0.1.0-alpha.3}" +readonly SMM_RELEASE_BASE_URL="${SMM_RELEASE_BASE_URL:-https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/${SMM_RELEASE_VERSION}}" + +log() { printf '[+] %s\n' "$*"; } +warn() { printf '[!] %s\n' "$*" >&2; } +die() { printf '[x] %s\n' "$*" >&2; exit 1; } + +require_root() { + [[ "$EUID" -eq 0 ]] || die "Запустите скрипт через sudo" +} + +detect_system() { + [[ -r /etc/os-release ]] || die "Не найден /etc/os-release" + # shellcheck disable=SC1091 + . /etc/os-release + case "${ID:-}" in + ubuntu|debian) ;; + *) die "Поддерживаются Ubuntu и Debian; обнаружено: ${ID:-unknown}" ;; + esac + command -v systemctl >/dev/null 2>&1 || die "Требуется systemd" +} + +read_public_key() { + if [[ -n "${SERVER_MONITOR_PUBLIC_KEY:-}" ]]; then + PUBLIC_KEY="$SERVER_MONITOR_PUBLIC_KEY" + log "Публичный ключ получен из SERVER_MONITOR_PUBLIC_KEY" + else + printf '\nВ Windows-приложении нажмите «SSH-ключ» → «Копировать».\n' + if [[ -r /dev/tty ]]; then + IFS= read -r -p 'Вставьте публичный ключ: ' PUBLIC_KEY < /dev/tty + else + die "Нет интерактивного терминала. Передайте ключ через SERVER_MONITOR_PUBLIC_KEY" + fi + fi + PUBLIC_KEY="${PUBLIC_KEY//$'\r'/}" + [[ "$PUBLIC_KEY" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/]+={0,3}([[:space:]].*)?$ ]] \ + || die "Ожидается публичный ключ формата ssh-ed25519 AAAA..." +} + +install_monitor_dependencies() { + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ca-certificates openssh-server openssh-client coreutils gawk sudo + systemctl enable --now ssh +} + +install_mesh_dependencies() { + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + wireguard-tools nftables iproute2 iputils-ping jq openssl +} + +verify_public_key() { + local key_file + key_file="$(mktemp)" + trap 'rm -f -- "${key_file:-}"' RETURN + printf '%s\n' "$PUBLIC_KEY" > "$key_file" + ssh-keygen -l -f "$key_file" >/dev/null \ + || die "ssh-keygen отклонил публичный ключ" + trap - RETURN + rm -f -- "$key_file" +} + +create_monitor_command() { + install -d -m 0755 -o root -g root "$(dirname "$MONITOR_COMMAND")" + [[ ! -L "$MONITOR_COMMAND" ]] \ + || die "Отказ от записи через символическую ссылку: $MONITOR_COMMAND" + cat > "$MONITOR_COMMAND" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +export LC_ALL=C + +readonly HUB_HELPER="/usr/local/libexec/ochenstarik-smm-hub" +original_command="${SSH_ORIGINAL_COMMAND:-metrics}" + +case "$original_command" in + ""|metrics) ;; + "mesh nodes") + [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } + exec sudo -n "$HUB_HELPER" nodes + ;; + "mesh links") + [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } + exec sudo -n "$HUB_HELPER" links + ;; + "mesh status") + [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } + exec sudo -n "$HUB_HELPER" status + ;; + "mesh connect "*) + [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } + read -r prefix action source target protocol port ttl extra <<< "$original_command" + [[ "$prefix" == mesh && "$action" == connect && -n "$ttl" && -z "${extra:-}" ]] \ + || { echo "Некорректная команда" >&2; exit 2; } + exec sudo -n "$HUB_HELPER" link-connect "$source" "$target" "$protocol" "$port" "$ttl" + ;; + "mesh disconnect "*) + [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } + read -r prefix action source target protocol port extra <<< "$original_command" + [[ "$prefix" == mesh && "$action" == disconnect && -n "$port" && -z "${extra:-}" ]] \ + || { echo "Некорректная команда" >&2; exit 2; } + exec sudo -n "$HUB_HELPER" link-disconnect "$source" "$target" "$protocol" "$port" + ;; + *) + echo "Разрешены только metrics и команды mesh" >&2 + exit 2 + ;; +esac + +read_mem_value() { + awk -v key="$1" '$1 == key ":" { print $2; exit }' /proc/meminfo +} + +printf 'PROTOCOL=1\n' +printf 'HOSTNAME=%s\n' "$(hostname)" +printf 'UPTIME_SECONDS=%s\n' "$(cut -d. -f1 /proc/uptime)" +printf 'LOAD1=%s\n' "$(cut -d' ' -f1 /proc/loadavg)" +printf 'CPU_COUNT=%s\n' "$(getconf _NPROCESSORS_ONLN)" +printf 'MEM_TOTAL_KB=%s\n' "$(read_mem_value MemTotal)" +printf 'MEM_AVAILABLE_KB=%s\n' "$(read_mem_value MemAvailable)" +printf 'SWAP_TOTAL_KB=%s\n' "$(read_mem_value SwapTotal)" +printf 'SWAP_FREE_KB=%s\n' "$(read_mem_value SwapFree)" +df -Pk / | awk 'NR == 2 { + printf "DISK_TOTAL_KB=%s\nDISK_AVAILABLE_KB=%s\n", $2, $4 +}' +df -Pi / | awk 'NR == 2 { + printf "DISK_INODES_TOTAL=%s\nDISK_INODES_FREE=%s\n", $2, $4 +}' +awk 'BEGIN { rx=0; tx=0 } + FNR == 1 && FILENAME !~ "/lo/" { rx += $1 } + FNR == 1 && FILENAME !~ "/lo/" { getline value < (FILENAME ~ /rx_bytes/ ? gensub(/rx_bytes$/, "tx_bytes", 1, FILENAME) : FILENAME); tx += value } + END { printf "NETWORK_RX_BYTES=%.0f\nNETWORK_TX_BYTES=%.0f\n", rx, tx } +' /sys/class/net/*/statistics/rx_bytes 2>/dev/null || printf 'NETWORK_RX_BYTES=0\nNETWORK_TX_BYTES=0\n' +printf 'SYSTEMD_SSH=%s\n' "$(systemctl is-active ssh 2>/dev/null || true)" +printf 'SYSTEMD_WIREGUARD=%s\n' "$(systemctl is-active wg-quick@smm0.service 2>/dev/null || true)" +printf 'KERNEL=%s\n' "$(uname -r)" +EOF + chown root:root "$MONITOR_COMMAND" + chmod 0755 "$MONITOR_COMMAND" +} + +create_monitor_user() { + if ! getent passwd "$MONITOR_USER" >/dev/null; then + useradd \ + --system \ + --create-home \ + --home-dir "$MONITOR_HOME" \ + --shell /bin/bash \ + "$MONITOR_USER" + fi + usermod --home "$MONITOR_HOME" --shell /bin/bash "$MONITOR_USER" + passwd -l "$MONITOR_USER" >/dev/null 2>&1 || true + + install -d -m 0750 -o "$MONITOR_USER" -g "$MONITOR_USER" "$MONITOR_HOME" + install -d -m 0700 -o "$MONITOR_USER" -g "$MONITOR_USER" "${MONITOR_HOME}/.ssh" + [[ ! -L "$AUTHORIZED_KEYS" ]] \ + || die "Отказ от записи через символическую ссылку: $AUTHORIZED_KEYS" + printf 'restrict,command="%s" %s\n' "$MONITOR_COMMAND" "$PUBLIC_KEY" > "$AUTHORIZED_KEYS" + chown "$MONITOR_USER:$MONITOR_USER" "$AUTHORIZED_KEYS" + chmod 0600 "$AUTHORIZED_KEYS" +} + +verify_sshd() { + sshd -t + sshd -T | grep -qi '^pubkeyauthentication yes$' \ + || die "В sshd отключена аутентификация по публичному ключу" +} + +install_monitoring() { + read_public_key + install_monitor_dependencies + verify_public_key + create_monitor_command + create_monitor_user + verify_sshd + systemctl reload ssh +} + +create_hub_helper() { + install -d -m 0755 -o root -g root "$(dirname "$HUB_HELPER")" + cat > "$HUB_HELPER" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +export LC_ALL=C + +readonly STATE_DIR="/etc/ochenstarik-server-monitor-manager" +readonly NODES_DIR="${STATE_DIR}/nodes" +readonly TOKENS_DIR="${STATE_DIR}/tokens" +readonly HUB_CONFIG="${STATE_DIR}/hub.conf" +readonly HUB_PRIVATE_KEY="${STATE_DIR}/hub.key" +readonly LINKS_FILE="${STATE_DIR}/links" +readonly AUDIT_FILE="${STATE_DIR}/audit.jsonl" +readonly VERSION_FILE="${STATE_DIR}/policy.version" +readonly WG_INTERFACE="smm0" +readonly WG_CONFIG="/etc/wireguard/${WG_INTERFACE}.conf" +readonly NFT_TABLE="ochenstarik_smm" + +die() { printf '[x] %s\n' "$*" >&2; exit 1; } +log() { printf '[+] %s\n' "$*"; } +require_root() { [[ "$EUID" -eq 0 ]] || die "Требуются права root"; } +valid_name() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; } + +config_value() { + local key="$1" file="$2" + awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$file" +} + +node_file() { printf '%s/%s.node' "$NODES_DIR" "$1"; } +node_value() { config_value "$2" "$(node_file "$1")"; } +node_exists() { [[ -f "$(node_file "$1")" ]]; } + +next_policy_version() { + local version=0 tmp + [[ ! -r "$VERSION_FILE" ]] || read -r version < "$VERSION_FILE" + [[ "$version" =~ ^[0-9]+$ ]] || version=0 + version=$((version + 1)) + tmp="$(mktemp)" + printf '%s\n' "$version" > "$tmp" + install -m 0600 -o root -g root "$tmp" "$VERSION_FILE" + rm -f -- "$tmp" + printf '%s' "$version" +} + +audit_link() { + local action="$1" state="$2" source="$3" target="$4" protocol="$5" port="$6" version="$7" + printf '{"time":"%s","action":"%s","state":"%s","source":"%s","target":"%s","protocol":"%s","port":%s,"version":%s}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$action" "$state" "$source" "$target" "$protocol" "$port" "$version" \ + >> "$AUDIT_FILE" + chmod 0600 "$AUDIT_FILE" +} + +render_wireguard_config() { + local tmp name file public_key address + tmp="$(mktemp)" + { + printf '[Interface]\n' + printf 'Address = %s/24\n' "$(config_value HUB_ADDRESS "$HUB_CONFIG")" + printf 'ListenPort = %s\n' "$(config_value WG_PORT "$HUB_CONFIG")" + printf 'PrivateKey = %s\n' "$(<"$HUB_PRIVATE_KEY")" + for file in "$NODES_DIR"/*.node; do + [[ -e "$file" ]] || continue + name="$(config_value NAME "$file")" + public_key="$(config_value PUBLIC_KEY "$file")" + address="$(config_value ADDRESS "$file")" + printf '\n# SMM-NODE %s\n' "$name" + printf '[Peer]\nPublicKey = %s\nAllowedIPs = %s/32\n' "$public_key" "$address" + done + } > "$tmp" + install -m 0600 -o root -g root "$tmp" "$WG_CONFIG" + rm -f -- "$tmp" + if ip link show "$WG_INTERFACE" >/dev/null 2>&1; then + wg syncconf "$WG_INTERFACE" <(wg-quick strip "$WG_INTERFACE") + fi +} + +next_address() { + local host address file used + for host in $(seq 2 254); do + address="10.77.0.${host}" + used=false + for file in "$NODES_DIR"/*.node "$TOKENS_DIR"/*.token*; do + [[ -e "$file" ]] || continue + if [[ "$(config_value ADDRESS "$file")" == "$address" ]]; then + used=true + break + fi + done + [[ "$used" == true ]] || { printf '%s' "$address"; return 0; } + done + die "В подсети 10.77.0.0/24 закончились адреса" +} + +base64url_encode() { + base64 -w0 | tr '+/' '-_' | tr -d '=' +} + +base64url_decode() { + local data="$1" remainder + data="${data//-/+}" + data="${data//_/\/}" + remainder=$(( ${#data} % 4 )) + if (( remainder == 2 )); then data+='==' + elif (( remainder == 3 )); then data+='=' + elif (( remainder == 1 )); then return 1 + fi + printf '%s' "$data" | base64 -d +} + +payload_value() { + local payload="$1" key="$2" + printf '%s\n' "$payload" | awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' +} + +token_cleanup() { + local file expires now + now="$(date +%s)" + for file in "$TOKENS_DIR"/*.token; do + [[ -e "$file" ]] || continue + expires="$(config_value EXPIRES "$file")" + [[ "$expires" =~ ^[0-9]+$ ]] || { rm -f -- "$file"; continue; } + (( expires > now )) || rm -f -- "$file" + done +} + +node_code() { + local name="$1" address endpoint port payload code token token_hash expires file + valid_name "$name" || die "Имя: строчные латинские буквы, цифры и дефис, максимум 32 символа" + node_exists "$name" && die "Узел $name уже существует" + token_cleanup + for file in "$TOKENS_DIR"/*.token; do + [[ -e "$file" ]] || continue + [[ "$(config_value NAME "$file")" != "$name" ]] \ + || die "Для узла $name уже существует действующий enrollment-код" + done + address="$(next_address)" + endpoint="$(config_value HUB_ENDPOINT "$HUB_CONFIG")" + port="$(config_value WG_PORT "$HUB_CONFIG")" + token="$(openssl rand -hex 32)" + token_hash="$(printf '%s' "$token" | sha256sum | awk '{ print $1 }')" + expires=$(( $(date +%s) + 600 )) + file="${TOKENS_DIR}/${token_hash}.token" + umask 077 + { + printf 'NAME=%s\n' "$name" + printf 'ADDRESS=%s\n' "$address" + printf 'EXPIRES=%s\n' "$expires" + } > "$file" + payload="$(printf 'VERSION=2\nNAME=%s\nADDRESS=%s/32\nTOKEN=%s\nEXPIRES=%s\nHUB_PUBLIC_KEY=%s\nENDPOINT=%s:%s\nALLOWED_IPS=10.77.0.0/24\n' \ + "$name" "$address" "$token" "$expires" "$(wg pubkey < "$HUB_PRIVATE_KEY")" "$endpoint" "$port")" + code="SMM2-$(printf '%s' "$payload" | base64url_encode)" + unset token payload + printf '\nОдноразовый enrollment-код для узла %s (действует 10 минут):\n%s\n\n' "$name" "$code" + printf 'Приватный WireGuard-ключ будет создан только на Node. Не сохраняйте код в чатах или логах.\n' +} + +node_enroll() { + local request="${SMM_ENROLL_REQUEST:-}" payload version token token_hash token_file consuming_file + local name address public_key stored_name stored_address expires now node_path ack + local hub_public_key endpoint allowed_ips + if [[ -z "$request" && -r /dev/tty ]]; then + IFS= read -r -s -p 'Вставьте request-код SMMREQ1: ' request < /dev/tty + printf '\n' + fi + [[ "$request" == SMMREQ1-* ]] || die "Ожидается request-код SMMREQ1-..." + payload="$(base64url_decode "${request#SMMREQ1-}")" || die "Не удалось декодировать request-код" + version="$(payload_value "$payload" VERSION)" + token="$(payload_value "$payload" TOKEN)" + name="$(payload_value "$payload" NAME)" + address="$(payload_value "$payload" ADDRESS)" + public_key="$(payload_value "$payload" PUBLIC_KEY)" + [[ "$version" == 1 ]] || die "Неподдерживаемая версия request-кода" + [[ "$token" =~ ^[a-f0-9]{64}$ ]] || die "Некорректный enrollment token" + valid_name "$name" || die "Некорректное имя Node" + [[ "$address" =~ ^10\.77\.0\.([2-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])/32$ ]] \ + || die "Некорректный адрес Node" + [[ "$(printf '%s' "$public_key" | base64 -d 2>/dev/null | wc -c)" -eq 32 ]] \ + || die "Некорректный публичный WireGuard-ключ Node" + token_hash="$(printf '%s' "$token" | sha256sum | awk '{ print $1 }')" + token_file="${TOKENS_DIR}/${token_hash}.token" + [[ -f "$token_file" ]] || die "Enrollment token не найден, уже использован или истёк" + stored_name="$(config_value NAME "$token_file")" + stored_address="$(config_value ADDRESS "$token_file")" + expires="$(config_value EXPIRES "$token_file")" + now="$(date +%s)" + [[ "$stored_name" == "$name" && "${address%/32}" == "$stored_address" ]] \ + || die "Параметры Node не совпадают с enrollment token" + [[ "$expires" =~ ^[0-9]+$ ]] && (( expires > now )) \ + || { rm -f -- "$token_file"; die "Enrollment token истёк"; } + node_exists "$name" && die "Узел $name уже зарегистрирован" + consuming_file="${token_file}.consuming" + mv -- "$token_file" "$consuming_file" \ + || die "Enrollment token уже обрабатывается" + node_path="$(node_file "$name")" + umask 077 + { + printf 'NAME=%s\n' "$name" + printf 'ADDRESS=%s\n' "${address%/32}" + printf 'PUBLIC_KEY=%s\n' "$public_key" + } > "$node_path" + render_wireguard_config + rm -f -- "$consuming_file" + hub_public_key="$(wg pubkey < "$HUB_PRIVATE_KEY")" + endpoint="$(config_value HUB_ENDPOINT "$HUB_CONFIG"):$(config_value WG_PORT "$HUB_CONFIG")" + allowed_ips="10.77.0.0/24" + ack="$(printf '%s|%s|%s|%s|%s|%s|%s' \ + "$token" "$public_key" "$name" "$address" "$hub_public_key" "$endpoint" "$allowed_ips" \ + | sha256sum | awk '{ print $1 }')" + unset token payload + printf '\nNode зарегистрирован. Верните этот код в установщик Node:\nSMMACK1-%s\n\n' "$ack" +} + +prune_links() { + local tmp source target cidr protocol port expires version now target_ip + tmp="$(mktemp)" + now="$(date +%s)" + if [[ -f "$LINKS_FILE" ]]; then + while read -r source target cidr protocol port expires version extra; do + [[ -z "${extra:-}" ]] || continue + valid_name "$source" && valid_name "$target" || continue + node_exists "$source" && node_exists "$target" || continue + target_ip="$(node_value "$target" ADDRESS)" + [[ "$cidr" == "${target_ip}/32" ]] || continue + [[ "$protocol" == tcp || "$protocol" == udp ]] || continue + [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) || continue + [[ "$expires" =~ ^[0-9]+$ ]] || continue + [[ "${version:-}" =~ ^[0-9]+$ ]] || version=0 + if (( expires != 0 && expires <= now )); then + audit_link expire Expired "$source" "$target" "$protocol" "$port" "$version" + continue + fi + printf '%s %s %s %s %s %s %s\n' "$source" "$target" "$cidr" "$protocol" "$port" "$expires" "$version" >> "$tmp" + done < "$LINKS_FILE" + fi + sort -u -o "$tmp" "$tmp" + install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" + rm -f -- "$tmp" +} + +restore_firewall() { + local tmp body source target cidr protocol port expires version source_ip + prune_links + tmp="$(mktemp)" + body="$(mktemp)" + { + printf 'table inet %s {\n' "$NFT_TABLE" + printf ' chain forward {\n' + printf ' type filter hook forward priority 10; policy accept;\n' + printf ' iifname "%s" oifname "%s" ct state established,related accept\n' "$WG_INTERFACE" "$WG_INTERFACE" + while read -r source target cidr protocol port expires version; do + [[ -n "$source" ]] || continue + source_ip="$(node_value "$source" ADDRESS)" + printf ' iifname "%s" oifname "%s" ip saddr %s ip daddr %s %s dport %s accept\n' \ + "$WG_INTERFACE" "$WG_INTERFACE" "$source_ip" "$cidr" "$protocol" "$port" + done < "$LINKS_FILE" + printf ' iifname "%s" oifname "%s" drop\n' "$WG_INTERFACE" "$WG_INTERFACE" + printf ' }\n}\n' + } > "$body" + if nft list table inet "$NFT_TABLE" >/dev/null 2>&1; then + printf 'delete table inet %s\n' "$NFT_TABLE" > "$tmp" + fi + cat "$body" >> "$tmp" + rm -f -- "$body" + if ! nft --check -f "$tmp" || ! nft -f "$tmp"; then + rm -f -- "$tmp" + return 1 + fi + rm -f -- "$tmp" +} + +link_connect() { + local source="$1" target="$2" protocol="$3" port="$4" ttl_minutes="$5" + local target_ip cidr expires version tmp + valid_name "$source" && valid_name "$target" || die "Некорректное имя узла" + [[ "$source" != "$target" ]] || die "Источник и назначение совпадают" + node_exists "$source" || die "Узел $source не найден" + node_exists "$target" || die "Узел $target не найден" + [[ "$protocol" == tcp || "$protocol" == udp ]] || die "Протокол должен быть tcp или udp" + [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) || die "Порт должен быть от 1 до 65535" + [[ "$ttl_minutes" =~ ^[0-9]+$ ]] && (( ttl_minutes <= 525600 )) \ + || die "TTL должен быть от 0 до 525600 минут" + target_ip="$(node_value "$target" ADDRESS)" + cidr="${target_ip}/32" + expires=0 + (( ttl_minutes == 0 )) || expires=$(( $(date +%s) + ttl_minutes * 60 )) + version="$(next_policy_version)" + audit_link connect Connecting "$source" "$target" "$protocol" "$port" "$version" + tmp="$(mktemp)" + if [[ -f "$LINKS_FILE" ]]; then + awk -v source="$source" -v target="$target" -v protocol="$protocol" -v port="$port" \ + '!($1 == source && $2 == target && $4 == protocol && $5 == port)' "$LINKS_FILE" > "$tmp" + fi + printf '%s %s %s %s %s %s %s\n' "$source" "$target" "$cidr" "$protocol" "$port" "$expires" "$version" >> "$tmp" + sort -u -o "$tmp" "$tmp" + install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" + rm -f -- "$tmp" + if ! restore_firewall; then + audit_link connect Failed "$source" "$target" "$protocol" "$port" "$version" + die "Не удалось применить nftables policy" + fi + audit_link connect Active "$source" "$target" "$protocol" "$port" "$version" + log "Связь $source → $target: $protocol/$port включена" + printf 'LINK_STATE=Active|VERSION=%s\n' "$version" +} + +link_disconnect() { + local source="$1" target="$2" protocol="$3" port="$4" version tmp + valid_name "$source" && valid_name "$target" || die "Некорректное имя узла" + [[ "$protocol" == tcp || "$protocol" == udp ]] || die "Протокол должен быть tcp или udp" + [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) || die "Некорректный порт" + version="$(next_policy_version)" + audit_link disconnect Disconnecting "$source" "$target" "$protocol" "$port" "$version" + tmp="$(mktemp)" + if [[ -f "$LINKS_FILE" ]]; then + awk -v source="$source" -v target="$target" -v protocol="$protocol" -v port="$port" \ + '!($1 == source && $2 == target && $4 == protocol && $5 == port)' "$LINKS_FILE" > "$tmp" + fi + install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" + rm -f -- "$tmp" + if ! restore_firewall; then + audit_link disconnect Failed "$source" "$target" "$protocol" "$port" "$version" + die "Не удалось применить отключение в nftables" + fi + audit_link disconnect Disabled "$source" "$target" "$protocol" "$port" "$version" + log "Связь $source → $target: $protocol/$port отключена" + printf 'LINK_STATE=Disabled|VERSION=%s\n' "$version" +} + +remove_node() { + local name="$1" public_key tmp + valid_name "$name" || die "Некорректное имя узла" + node_exists "$name" || die "Узел $name не найден" + public_key="$(node_value "$name" PUBLIC_KEY)" + wg set "$WG_INTERFACE" peer "$public_key" remove 2>/dev/null || true + rm -f -- "$(node_file "$name")" + tmp="$(mktemp)" + if [[ -f "$LINKS_FILE" ]]; then + awk -v name="$name" '$1 != name && $2 != name' "$LINKS_FILE" > "$tmp" + fi + install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" + rm -f -- "$tmp" + render_wireguard_config + restore_firewall + log "Узел $name удалён" +} + +list_nodes() { + local file name address public_key handshake now state age + now="$(date +%s)" + for file in "$NODES_DIR"/*.node; do + [[ -e "$file" ]] || continue + name="$(config_value NAME "$file")" + address="$(config_value ADDRESS "$file")" + public_key="$(config_value PUBLIC_KEY "$file")" + handshake="$(wg show "$WG_INTERFACE" latest-handshakes 2>/dev/null | awk -v key="$public_key" '$1 == key { print $2; exit }')" + handshake="${handshake:-0}" + state=offline + age=-1 + if [[ "$handshake" =~ ^[0-9]+$ && "$handshake" -gt 0 ]]; then + age=$((now - handshake)) + (( age <= 180 )) && state=online + fi + printf 'NODE=%s|%s|%s|%s\n' "$name" "$address" "$state" "$age" + done +} + +list_links() { + local source target cidr protocol port expires version + prune_links + [[ -f "$LINKS_FILE" ]] || return 0 + while read -r source target cidr protocol port expires version; do + [[ -n "$source" && -n "$target" ]] || continue + printf 'LINK=%s|%s|%s|%s|%s|%s|Active|%s\n' \ + "$source" "$target" "$cidr" "$protocol" "$port" "$expires" "$version" + done < "$LINKS_FILE" +} + +status() { + printf 'ROLE=hub\nINTERFACE=%s\nADDRESS=%s\nENDPOINT=%s:%s\n' \ + "$WG_INTERFACE" \ + "$(config_value HUB_ADDRESS "$HUB_CONFIG")" \ + "$(config_value HUB_ENDPOINT "$HUB_CONFIG")" \ + "$(config_value WG_PORT "$HUB_CONFIG")" + list_nodes + list_links +} + +main() { + local action="${1:-status}" + require_root + [[ -r "$HUB_CONFIG" ]] || die "Mesh Hub не установлен" + case "$action" in + node-code) [[ $# -eq 2 ]] || die "Использование: $0 node-code NAME"; node_code "$2" ;; + node-enroll) [[ $# -eq 1 ]] || die "Использование: $0 node-enroll"; node_enroll ;; + node-remove) [[ $# -eq 2 ]] || die "Использование: $0 node-remove NAME"; remove_node "$2" ;; + nodes) list_nodes ;; + links) list_links ;; + link-connect) [[ $# -eq 6 ]] || die "Использование: $0 link-connect SOURCE TARGET tcp|udp PORT TTL_MINUTES"; link_connect "$2" "$3" "$4" "$5" "$6" ;; + link-disconnect) [[ $# -eq 5 ]] || die "Использование: $0 link-disconnect SOURCE TARGET tcp|udp PORT"; link_disconnect "$2" "$3" "$4" "$5" ;; + render) render_wireguard_config ;; + firewall-restore) restore_firewall ;; + status) status ;; + *) die "Команды: node-code, node-enroll, node-remove, nodes, links, link-connect, link-disconnect, status" ;; + esac +} + +main "$@" +EOF + chown root:root "$HUB_HELPER" + chmod 0750 "$HUB_HELPER" + ln -sfn "$HUB_HELPER" "$HUB_CLI" +} + +configure_hub_firewall_service() { + cat > /etc/systemd/system/ochenstarik-smm-firewall.service < /etc/systemd/system/ochenstarik-smm-firewall.timer <<'EOF' +[Unit] +Description=Expire Server Monitor mesh policies + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min +AccuracySec=10s +Persistent=true +Unit=ochenstarik-smm-firewall.service + +[Install] +WantedBy=timers.target +EOF + chmod 0644 /etc/systemd/system/ochenstarik-smm-firewall.timer + systemctl daemon-reload + systemctl enable --now ochenstarik-smm-firewall.service + systemctl enable --now ochenstarik-smm-firewall.timer +} + +configure_hub_sudo() { + { + printf '%s ALL=(root) NOPASSWD: %s *\n' "$MONITOR_USER" "$HUB_HELPER" + if getent passwd "$CONTROL_USER" >/dev/null; then + printf '%s ALL=(root) NOPASSWD: %s *\n' "$CONTROL_USER" "$CONTROL_POLICY_HELPER" + fi + } > /etc/sudoers.d/ochenstarik-smm-hub + chmod 0440 /etc/sudoers.d/ochenstarik-smm-hub + visudo -cf /etc/sudoers.d/ochenstarik-smm-hub >/dev/null \ + || die "Некорректный sudoers-файл Mesh Hub" +} + +create_control_policy_helper() { + cat > "$CONTROL_POLICY_HELPER" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly HUB_HELPER="/usr/local/libexec/ochenstarik-smm-hub" +action="${1:-}" + +valid_name() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; } +valid_protocol() { [[ "$1" == tcp || "$1" == udp ]]; } +valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 )); } + +case "$action" in + link-connect) + [[ $# -eq 6 ]] || exit 2 + valid_name "$2" && valid_name "$3" && valid_protocol "$4" && valid_port "$5" \ + && [[ "$6" =~ ^[0-9]+$ ]] && (( $6 <= 525600 )) || exit 2 + ;; + link-disconnect) + [[ $# -eq 5 ]] || exit 2 + valid_name "$2" && valid_name "$3" && valid_protocol "$4" && valid_port "$5" || exit 2 + ;; + *) exit 2 ;; +esac + +exec "$HUB_HELPER" "$@" +EOF + chown root:root "$CONTROL_POLICY_HELPER" + chmod 0750 "$CONTROL_POLICY_HELPER" +} + +read_hub_endpoint() { + local endpoint port + if [[ -n "${SMM_HUB_ENDPOINT:-}" ]]; then + endpoint="$SMM_HUB_ENDPOINT" + elif [[ -r /dev/tty ]]; then + IFS= read -r -p 'Публичный IPv4 или домен главного сервера: ' endpoint < /dev/tty + else + die "Передайте адрес через SMM_HUB_ENDPOINT" + fi + endpoint="${endpoint//$'\r'/}" + [[ "$endpoint" =~ ^[A-Za-z0-9.-]+$ ]] || die "Некорректный IPv4 или домен" + + if [[ -n "${SMM_WG_PORT:-}" ]]; then + port="$SMM_WG_PORT" + elif [[ -r /dev/tty ]]; then + IFS= read -r -p "UDP-порт WireGuard [${DEFAULT_WG_PORT}]: " port < /dev/tty + port="${port:-$DEFAULT_WG_PORT}" + else + port="$DEFAULT_WG_PORT" + fi + [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) \ + || die "Порт должен быть от 1 до 65535" + HUB_ENDPOINT_VALUE="$endpoint" + WG_PORT_VALUE="$port" +} + +runtime_id() { + case "$(uname -m)" in + x86_64|amd64) printf 'linux-x64\n' ;; + aarch64|arm64) printf 'linux-arm64\n' ;; + *) die "Control layer поддерживает только amd64 и arm64" ;; + esac +} + +download_control_layer() { + local runtime archive archive_name checksum temporary + runtime="$(runtime_id)" + archive_name="server-monitor-manager-${runtime}.tar.gz" + temporary="$(mktemp -d)" + archive="${temporary}/${archive_name}" + trap 'rm -rf -- "${temporary:-}"' RETURN + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl openssl tar + curl -4 -fL --retry 5 --retry-delay 5 --connect-timeout 30 \ + "${SMM_RELEASE_BASE_URL}/${archive_name}" -o "$archive" + curl -4 -fL --retry 5 --retry-delay 5 --connect-timeout 30 \ + "${SMM_RELEASE_BASE_URL}/${archive_name}.sha256" -o "${archive}.sha256" + checksum="$(awk 'NR == 1 { print $1 }' "${archive}.sha256")" + [[ "$checksum" =~ ^[a-fA-F0-9]{64}$ ]] || die "Некорректный release checksum" + [[ "$(sha256sum "$archive" | awk '{ print $1 }')" == "$checksum" ]] \ + || die "Checksum control layer не совпал" + tar -xzf "$archive" -C "$temporary" + [[ -x "${temporary}/agent/ochenstarik-smm-agent" ]] || die "В release нет Agent" + [[ -x "${temporary}/control/ochenstarik-smm-control" ]] || die "В release нет Control" + install -d -m 0755 -o root -g root "$(dirname "$AGENT_BINARY")" "$(dirname "$CONTROL_BINARY")" + install -m 0755 -o root -g root "${temporary}/agent/ochenstarik-smm-agent" "$AGENT_BINARY" + install -m 0755 -o root -g root "${temporary}/control/ochenstarik-smm-control" "$CONTROL_BINARY" + trap - RETURN + rm -rf -- "$temporary" +} + +ensure_system_user() { + local user="$1" home="$2" + if ! getent passwd "$user" >/dev/null; then + useradd --system --home-dir "$home" --create-home --shell /usr/sbin/nologin "$user" + fi + install -d -m 0700 -o "$user" -g "$user" "$home" +} + +create_control_certificates() { + local endpoint="$1" san temporary + [[ -f "${CONTROL_STATE}/control-ca.pfx" && -f "${CONTROL_STATE}/server.pfx" && -f "$CONTROL_CA_CERT" ]] \ + && return 0 + temporary="$(mktemp -d)" + trap 'rm -rf -- "${temporary:-}"' RETURN + if [[ "$endpoint" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + san="IP:${endpoint}" + else + san="DNS:${endpoint}" + fi + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -sha256 -nodes \ + -days 3650 -subj '/CN=Ochenstarik SMM Control CA' \ + -keyout "${temporary}/ca.key" -out "${temporary}/ca.crt" >/dev/null 2>&1 + openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -sha256 -nodes \ + -subj "/CN=${endpoint}" -addext "subjectAltName=${san}" \ + -keyout "${temporary}/server.key" -out "${temporary}/server.csr" >/dev/null 2>&1 + openssl x509 -req -sha256 -days 825 -in "${temporary}/server.csr" \ + -CA "${temporary}/ca.crt" -CAkey "${temporary}/ca.key" -CAcreateserial \ + -copy_extensions copy -out "${temporary}/server.crt" >/dev/null 2>&1 + openssl pkcs12 -export -passout pass: -name smm-control-ca \ + -inkey "${temporary}/ca.key" -in "${temporary}/ca.crt" \ + -out "${temporary}/control-ca.pfx" + openssl pkcs12 -export -passout pass: -name smm-control \ + -inkey "${temporary}/server.key" -in "${temporary}/server.crt" \ + -certfile "${temporary}/ca.crt" -out "${temporary}/server.pfx" + install -m 0600 -o "$CONTROL_USER" -g "$CONTROL_USER" \ + "${temporary}/control-ca.pfx" "${CONTROL_STATE}/control-ca.pfx" + install -m 0600 -o "$CONTROL_USER" -g "$CONTROL_USER" \ + "${temporary}/server.pfx" "${CONTROL_STATE}/server.pfx" + install -m 0644 -o root -g root "${temporary}/ca.crt" "$CONTROL_CA_CERT" + trap - RETURN + rm -rf -- "$temporary" +} + +configure_control_service() { + cat > "$CONTROL_ENV" < "$CONTROL_SERVICE" </dev/null 2>&1 && ufw status | grep -q '^Status: active'; then + ufw allow "${CONTROL_PORT}/tcp" comment 'Server Monitor Control' >/dev/null + fi + log "Control Hub установлен: https://${endpoint}:${CONTROL_PORT}" + log "Создать код Agent: sudo $0 control-code ИМЯ" +} + +install_hub() { + install_monitoring + install_mesh_dependencies + read_hub_endpoint + install -d -m 0700 -o root -g root "$MESH_DIR" "$NODES_DIR" "$TOKENS_DIR" + install -d -m 0700 -o root -g root /etc/wireguard + [[ -f "$HUB_PRIVATE_KEY" ]] || { umask 077; wg genkey > "$HUB_PRIVATE_KEY"; } + chmod 0600 "$HUB_PRIVATE_KEY" + { + printf 'HUB_ENDPOINT=%s\n' "$HUB_ENDPOINT_VALUE" + printf 'WG_PORT=%s\n' "$WG_PORT_VALUE" + printf 'HUB_ADDRESS=%s\n' "$HUB_ADDRESS" + printf 'MESH_CIDR=%s\n' "$MESH_CIDR" + } > "$HUB_CONFIG" + chmod 0600 "$HUB_CONFIG" + touch "$LINKS_FILE" + chmod 0600 "$LINKS_FILE" + touch "${MESH_DIR}/audit.jsonl" + chmod 0600 "${MESH_DIR}/audit.jsonl" + [[ -f "${MESH_DIR}/policy.version" ]] || printf '0\n' > "${MESH_DIR}/policy.version" + chmod 0600 "${MESH_DIR}/policy.version" + create_hub_helper + "$HUB_HELPER" render + "$HUB_HELPER" firewall-restore + cat > /etc/sysctl.d/90-ochenstarik-smm-forward.conf <<'EOF' +net.ipv4.ip_forward = 1 +EOF + chmod 0644 /etc/sysctl.d/90-ochenstarik-smm-forward.conf + sysctl -p /etc/sysctl.d/90-ochenstarik-smm-forward.conf >/dev/null + "$HUB_HELPER" firewall-restore + systemctl enable --now "wg-quick@${WG_INTERFACE}.service" + configure_hub_firewall_service + configure_hub_sudo + if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active'; then + ufw allow "$WG_PORT_VALUE/udp" comment 'Server Monitor Mesh' >/dev/null + ufw route allow in on "$WG_INTERFACE" out on "$WG_INTERFACE" comment 'Server Monitor Mesh routed' >/dev/null + fi + log "Главный Mesh Hub установлен: ${HUB_ENDPOINT_VALUE}:${WG_PORT_VALUE}/udp" + log "Создать код узла: sudo ochenstarik-smm node-code ai-agent" + log "Включить SSH на 2 часа: sudo ochenstarik-smm link-connect ai-agent home tcp 22 120" + log "Установить постоянный control layer: sudo $0 install-control-hub" +} + +base64url_encode() { + base64 -w0 | tr '+/' '-_' | tr -d '=' +} + +base64url_decode() { + local data="$1" remainder + data="${data//-/+}" + data="${data//_/\/}" + remainder=$(( ${#data} % 4 )) + if (( remainder == 2 )); then data+='==' + elif (( remainder == 3 )); then data+='=' + elif (( remainder == 1 )); then return 1 + fi + printf '%s' "$data" | base64 -d +} + +payload_value() { + local payload="$1" key="$2" + printf '%s\n' "$payload" | awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' +} + +create_control_join_code() { + local name="$1" endpoint token ca payload + [[ -x "$CONTROL_BINARY" && -r "$CONTROL_ENV" && -r "$CONTROL_CA_CERT" ]] \ + || die "Control Hub не установлен" + [[ "$name" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || die "Некорректное имя Agent" + endpoint="$(awk -F= '$1 == "HUB_ENDPOINT" { print $2; exit }' "$HUB_CONFIG")" + token="$( + set -a + # shellcheck disable=SC1090 + . "$CONTROL_ENV" + set +a + runuser -u "$CONTROL_USER" -m -- "$CONTROL_BINARY" token-create "$name" + )" + [[ "$token" =~ ^[A-Za-z0-9_-]{43}$ ]] || die "Control Hub не создал enrollment token" + ca="$(base64 -w0 "$CONTROL_CA_CERT")" + payload="$(printf 'VERSION=1\nNAME=%s\nURL=https://%s:%s\nTOKEN=%s\nCA=%s\n' \ + "$name" "$endpoint" "$CONTROL_PORT" "$token" "$ca")" + printf 'SMMCTL1-%s\n' "$(printf '%s' "$payload" | base64url_encode)" + unset token payload ca +} + +create_device_join_code() { + local device_id="$1" endpoint token ca payload + [[ -x "$CONTROL_BINARY" && -r "$CONTROL_ENV" && -r "$CONTROL_CA_CERT" ]] \ + || die "Control Hub не установлен" + [[ "$device_id" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]] || die "Некорректное имя устройства" + endpoint="$(awk -F= '$1 == "HUB_ENDPOINT" { print $2; exit }' "$HUB_CONFIG")" + token="$( + set -a + # shellcheck disable=SC1090 + . "$CONTROL_ENV" + set +a + runuser -u "$CONTROL_USER" -m -- "$CONTROL_BINARY" device-token-create "$device_id" + )" + [[ "$token" =~ ^[A-Za-z0-9_-]{43}$ ]] || die "Control Hub не создал device token" + ca="$(base64 -w0 "$CONTROL_CA_CERT")" + payload="$(printf 'VERSION=1\nDEVICE=%s\nURL=https://%s:%s\nTOKEN=%s\nCA=%s\n' \ + "$device_id" "$endpoint" "$CONTROL_PORT" "$token" "$ca")" + printf 'SMMDEV1-%s\n' "$(printf '%s' "$payload" | base64url_encode)" + unset token payload ca +} + +read_control_join_code() { + if [[ -n "${SMM_CONTROL_CODE:-}" ]]; then + CONTROL_JOIN_CODE="$SMM_CONTROL_CODE" + elif [[ -r /dev/tty ]]; then + printf '\nНа Hub выполните: sudo %s control-code ИМЯ\n' "$0" + IFS= read -r -s -p 'Вставьте одноразовый код SMMCTL1: ' CONTROL_JOIN_CODE < /dev/tty + printf '\n' + else + die "Передайте код через SMM_CONTROL_CODE" + fi + CONTROL_JOIN_CODE="${CONTROL_JOIN_CODE//$'\r'/}" + [[ "$CONTROL_JOIN_CODE" == SMMCTL1-* ]] || die "Ожидается код формата SMMCTL1-..." +} + +configure_agent_service() { + local node_id="$1" control_url="$2" ca_path="${AGENT_STATE}/control-ca.crt" + cat > "$AGENT_ENV" < "$AGENT_SERVICE" < "$temporary" 2>/dev/null \ + || die "Некорректный CA в control code" + openssl x509 -in "$temporary" -noout -checkend 86400 >/dev/null \ + || die "Control CA недействителен или скоро истекает" + download_control_layer + ensure_system_user "$AGENT_USER" "$AGENT_STATE" + install -d -m 0700 -o root -g root "$MESH_DIR" + ca_path="${AGENT_STATE}/control-ca.crt" + install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$temporary" "$ca_path" + configure_agent_service "$name" "$control_url" + runuser -u "$AGENT_USER" -- env \ + "SMM_NodeId=${name}" \ + "SMM_ControlUrl=${control_url}" \ + "SMM_StateDirectory=${AGENT_STATE}" \ + "SMM_CertificateAuthorityPath=${ca_path}" \ + "SMM_EnrollToken=${token}" \ + "$AGENT_BINARY" + systemctl daemon-reload + systemctl enable --now ochenstarik-smm-agent.service + trap - RETURN + rm -f -- "$temporary" + unset token payload ca CONTROL_JOIN_CODE SMM_CONTROL_CODE + log "Control Agent $name зарегистрирован и запущен" +} + +read_join_code() { + if [[ -n "${SMM_JOIN_CODE:-}" ]]; then + JOIN_CODE="$SMM_JOIN_CODE" + elif [[ -r /dev/tty ]]; then + printf '\nНа главном сервере выполните: sudo ochenstarik-smm node-code ИМЯ\n' + IFS= read -r -s -p 'Вставьте одноразовый код SMM2: ' JOIN_CODE < /dev/tty + printf '\n' + else + die "Передайте код через SMM_JOIN_CODE" + fi + JOIN_CODE="${JOIN_CODE//$'\r'/}" + [[ "$JOIN_CODE" == SMM2-* ]] || die "Ожидается код формата SMM2-..." +} + +install_node_mesh() { + local payload version name address token expires now private_key public_key + local hub_public_key endpoint allowed_ips request_payload request_code ack expected_ack tmp ssh_port + install_mesh_dependencies + read_join_code + payload="$(base64url_decode "${JOIN_CODE#SMM2-}")" || die "Не удалось декодировать код" + version="$(payload_value "$payload" VERSION)" + name="$(payload_value "$payload" NAME)" + address="$(payload_value "$payload" ADDRESS)" + token="$(payload_value "$payload" TOKEN)" + expires="$(payload_value "$payload" EXPIRES)" + hub_public_key="$(payload_value "$payload" HUB_PUBLIC_KEY)" + endpoint="$(payload_value "$payload" ENDPOINT)" + allowed_ips="$(payload_value "$payload" ALLOWED_IPS)" + [[ "$version" == 2 ]] || die "Неподдерживаемая версия кода" + [[ "$name" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || die "Некорректное имя узла" + [[ "$address" =~ ^10\.77\.0\.([2-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])/32$ ]] \ + || die "Некорректный внутренний адрес" + [[ "$endpoint" =~ ^[A-Za-z0-9.-]+:[0-9]{1,5}$ ]] || die "Некорректный endpoint" + [[ "$allowed_ips" == "$MESH_CIDR" ]] || die "Некорректная mesh-подсеть" + [[ "$token" =~ ^[a-f0-9]{64}$ ]] || die "Некорректный enrollment token" + now="$(date +%s)" + [[ "$expires" =~ ^[0-9]+$ ]] && (( expires > now )) || die "Enrollment-код истёк" + [[ "$(printf '%s' "$hub_public_key" | base64 -d 2>/dev/null | wc -c)" -eq 32 ]] \ + || die "Некорректный публичный ключ Hub" + private_key="$(wg genkey)" + public_key="$(printf '%s' "$private_key" | wg pubkey)" + request_payload="$(printf 'VERSION=1\nTOKEN=%s\nNAME=%s\nADDRESS=%s\nPUBLIC_KEY=%s\n' \ + "$token" "$name" "$address" "$public_key")" + request_code="SMMREQ1-$(printf '%s' "$request_payload" | base64url_encode)" + printf '\nПриватный WireGuard-ключ создан локально и не покидает Node.\n' + printf 'Request-код:\n\n%s\n\n' "$request_code" + printf 'На Hub выполните: sudo ochenstarik-smm node-enroll\n' + printf 'Вставьте request-код по скрытому запросу и верните полученный SMMACK1.\n\n' + expected_ack="$(printf '%s|%s|%s|%s|%s|%s|%s' \ + "$token" "$public_key" "$name" "$address" "$hub_public_key" "$endpoint" "$allowed_ips" \ + | sha256sum | awk '{ print $1 }')" + if [[ -n "${SMM_ENROLL_ACK:-}" ]]; then + ack="$SMM_ENROLL_ACK" + ack="${ack//$'\r'/}" + [[ "$ack" == "SMMACK1-${expected_ack}" ]] \ + || die "Hub не подтвердил регистрацию этого Node" + elif [[ -r /dev/tty ]]; then + while true; do + IFS= read -r -s -p 'Вставьте ответ SMMACK1 от Hub: ' ack < /dev/tty + printf '\n' + ack="${ack//$'\r'/}" + [[ "$ack" == "SMMACK1-${expected_ack}" ]] && break + warn "Код не подтверждает этот Node. Повторите вставку или нажмите Ctrl+C." + done + else + die "Передайте подтверждение через SMM_ENROLL_ACK" + fi + install -d -m 0700 -o root -g root /etc/wireguard "$MESH_DIR" + tmp="$(mktemp)" + { + printf '[Interface]\nAddress = %s\nPrivateKey = %s\n' "$address" "$private_key" + printf '\n[Peer]\nPublicKey = %s\nEndpoint = %s\n' "$hub_public_key" "$endpoint" + printf 'AllowedIPs = %s\nPersistentKeepalive = 25\n' "$allowed_ips" + } > "$tmp" + install -m 0600 -o root -g root "$tmp" "$WG_CONFIG" + rm -f -- "$tmp" + printf 'ROLE=node\nNAME=%s\nADDRESS=%s\nPUBLIC_KEY=%s\n' \ + "$name" "$address" "$public_key" > "$MESH_DIR/node.conf" + chmod 0600 "$MESH_DIR/node.conf" + unset private_key token payload request_payload JOIN_CODE SMM_JOIN_CODE SMM_ENROLL_ACK ack expected_ack + systemctl enable --now "wg-quick@${WG_INTERFACE}.service" + ssh_port="$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }')" + if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active' && [[ -n "$ssh_port" ]]; then + ufw allow in on "$WG_INTERFACE" to any port "$ssh_port" proto tcp comment 'Server Monitor Mesh SSH' >/dev/null + fi + if ping -c 1 -W 4 "$HUB_ADDRESS" >/dev/null 2>&1; then + log "Узел $name подключён к Hub; адрес $address" + else + warn "Интерфейс поднят, но Hub пока не отвечает. Проверьте UDP-порт и команду sudo wg show." + fi +} + +install_node() { + install_monitoring + install_node_mesh + log "Вторичный сервер установлен" + log "После установки Control Hub добавьте Agent: sudo $0 install-control-agent" +} + +show_status() { + local ssh_port="unknown" + if command -v sshd >/dev/null 2>&1; then + ssh_port="$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }')" + fi + printf 'Пользователь мониторинга: %s\n' "$MONITOR_USER" + printf 'SSH-порт: %s\n' "${ssh_port:-unknown}" + if [[ -r "$HUB_CONFIG" && -x "$HUB_HELPER" ]]; then + "$HUB_HELPER" status + elif [[ -r "$MESH_DIR/node.conf" ]]; then + cat "$MESH_DIR/node.conf" + wg show "$WG_INTERFACE" 2>/dev/null || true + else + printf 'ROLE=monitor-only\n' + fi + if [[ -x "$MONITOR_COMMAND" ]]; then + runuser -u "$MONITOR_USER" -- "$MONITOR_COMMAND" + else + warn "Серверная часть ещё не установлена" + fi + printf 'Control Hub: %s\n' "$(systemctl is-active ochenstarik-smm-control.service 2>/dev/null || true)" + printf 'Control Agent: %s\n' "$(systemctl is-active ochenstarik-smm-agent.service 2>/dev/null || true)" +} + +install_server_part() { + install_monitoring + log "Серверная часть мониторинга установлена" + log "Входящий порт не изменялся, новые правила UFW не создавались" + show_status +} + +confirm_action() { + local prompt="$1" answer + if [[ -r /dev/tty ]]; then + IFS= read -r -p "$prompt [y/N]: " answer < /dev/tty + else + die "Для этой операции требуется интерактивный терминал" + fi + [[ "$answer" =~ ^[Yy]$ ]] +} + +backup_state() { + local backup_dir="/var/backups/${APP_NAME}" backup_file timestamp path + local -a paths=() + install -d -m 0700 -o root -g root "$backup_dir" + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + backup_file="${backup_dir}/${timestamp}.tar.gz" + for path in \ + "$MESH_DIR" "$MONITOR_HOME" "$MONITOR_COMMAND" "$HUB_HELPER" "$HUB_CLI" \ + "$CONTROL_POLICY_HELPER" "$WG_CONFIG" \ + /etc/systemd/system/ochenstarik-smm-firewall.service \ + /etc/systemd/system/ochenstarik-smm-firewall.timer \ + /etc/sudoers.d/ochenstarik-smm-hub \ + /etc/sysctl.d/90-ochenstarik-smm-forward.conf \ + "$CONTROL_SERVICE" "$AGENT_SERVICE" "$CONTROL_BINARY" "$AGENT_BINARY" \ + "$CONTROL_STATE" "$AGENT_STATE" "$CONTROL_ENV" "$AGENT_ENV" "$CONTROL_CA_CERT"; do + [[ -e "$path" || -L "$path" ]] && paths+=("${path#/}") + done + ((${#paths[@]} > 0)) || { warn "Нет установленных файлов для backup"; return 0; } + tar -C / -czf "$backup_file" -- "${paths[@]}" + chmod 0600 "$backup_file" + log "Backup: $backup_file" +} + +rollback_state() { + local backup_dir="/var/backups/${APP_NAME}" latest + latest="$(find "$backup_dir" -maxdepth 1 -type f -name '*.tar.gz' -printf '%T@ %p\n' 2>/dev/null \ + | sort -nr | awk 'NR == 1 { sub(/^[^ ]+ /, ""); print }')" + [[ -n "$latest" && -f "$latest" ]] || die "Backup для rollback не найден" + confirm_action "Восстановить $latest" || { log "Отменено"; return 0; } + tar -C / -xzf "$latest" + systemctl daemon-reload + [[ ! -f "$WG_CONFIG" ]] || systemctl restart "wg-quick@${WG_INTERFACE}.service" + [[ ! -x "$HUB_HELPER" ]] || "$HUB_HELPER" firewall-restore + [[ ! -x "$MONITOR_COMMAND" ]] || systemctl reload ssh + log "Rollback завершён: $latest" +} + +existing_public_key() { + [[ -r "$AUTHORIZED_KEYS" ]] || die "Не найден существующий SSH-ключ мониторинга" + sed -n 's/^.*\(ssh-ed25519 [A-Za-z0-9+\/=]*.*\)$/\1/p' "$AUTHORIZED_KEYS" | head -n 1 +} + +update_installed() { + local role=monitor + backup_state + SERVER_MONITOR_PUBLIC_KEY="$(existing_public_key)" + export SERVER_MONITOR_PUBLIC_KEY + if [[ -r "$HUB_CONFIG" ]]; then + role=hub + SMM_HUB_ENDPOINT="$(awk -F= '$1 == "HUB_ENDPOINT" { print $2; exit }' "$HUB_CONFIG")" + SMM_WG_PORT="$(awk -F= '$1 == "WG_PORT" { print $2; exit }' "$HUB_CONFIG")" + export SMM_HUB_ENDPOINT SMM_WG_PORT + install_hub + elif [[ -r "$MESH_DIR/node.conf" ]]; then + role=node + install_monitoring + install_mesh_dependencies + systemctl enable --now "wg-quick@${WG_INTERFACE}.service" + systemctl restart "wg-quick@${WG_INTERFACE}.service" + else + install_server_part + fi + unset SERVER_MONITOR_PUBLIC_KEY SMM_HUB_ENDPOINT SMM_WG_PORT + log "Обновление роли $role завершено" +} + +uninstall_monitor() { + [[ ! -r "$HUB_CONFIG" && ! -r "$MESH_DIR/node.conf" ]] \ + || die "Сначала удалите роль командой uninstall-hub или uninstall-node" + confirm_action "Удалить пользователя мониторинга и forced-command" || { log "Отменено"; return 0; } + backup_state + rm -f -- "$MONITOR_COMMAND" + if getent passwd "$MONITOR_USER" >/dev/null; then + userdel --remove "$MONITOR_USER" 2>/dev/null || userdel "$MONITOR_USER" + fi + log "Серверная часть мониторинга удалена" +} + +uninstall_node() { + local ssh_port + [[ -r "$MESH_DIR/node.conf" ]] || die "Роль Node не установлена" + confirm_action "Отключить и удалить WireGuard Node" || { log "Отменено"; return 0; } + backup_state + systemctl disable --now "wg-quick@${WG_INTERFACE}.service" 2>/dev/null || true + systemctl disable --now ochenstarik-smm-agent.service 2>/dev/null || true + ssh_port="$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }')" + if command -v ufw >/dev/null 2>&1 && [[ -n "$ssh_port" ]]; then + ufw --force delete allow in on "$WG_INTERFACE" to any port "$ssh_port" proto tcp >/dev/null 2>&1 || true + fi + rm -f -- "$WG_CONFIG" "$MESH_DIR/node.conf" "$AGENT_SERVICE" "$AGENT_ENV" + rm -rf -- "$AGENT_STATE" "$(dirname "$AGENT_BINARY")" + getent passwd "$AGENT_USER" >/dev/null && userdel "$AGENT_USER" 2>/dev/null || true + systemctl daemon-reload + rmdir "$MESH_DIR" 2>/dev/null || true + log "WireGuard Node удалён; monitoring identity оставлена" +} + +uninstall_hub() { + local wg_port + [[ -r "$HUB_CONFIG" ]] || die "Роль Hub не установлена" + confirm_action "Удалить Hub, все Node identities, Links и аудит" || { log "Отменено"; return 0; } + backup_state + wg_port="$(awk -F= '$1 == "WG_PORT" { print $2; exit }' "$HUB_CONFIG")" + systemctl disable --now ochenstarik-smm-firewall.timer ochenstarik-smm-firewall.service 2>/dev/null || true + systemctl disable --now ochenstarik-smm-control.service 2>/dev/null || true + systemctl disable --now "wg-quick@${WG_INTERFACE}.service" 2>/dev/null || true + nft delete table inet ochenstarik_smm >/dev/null 2>&1 || true + if command -v ufw >/dev/null 2>&1; then + ufw --force delete allow "$wg_port/udp" >/dev/null 2>&1 || true + ufw --force route delete allow in on "$WG_INTERFACE" out on "$WG_INTERFACE" >/dev/null 2>&1 || true + fi + rm -f -- \ + /etc/systemd/system/ochenstarik-smm-firewall.service \ + /etc/systemd/system/ochenstarik-smm-firewall.timer \ + /etc/sudoers.d/ochenstarik-smm-hub \ + /etc/sysctl.d/90-ochenstarik-smm-forward.conf \ + "$HUB_HELPER" "$HUB_CLI" "$CONTROL_POLICY_HELPER" "$WG_CONFIG" \ + "$CONTROL_SERVICE" "$CONTROL_ENV" "$CONTROL_CA_CERT" + rm -rf -- "$CONTROL_STATE" "$(dirname "$CONTROL_BINARY")" + getent passwd "$CONTROL_USER" >/dev/null && userdel "$CONTROL_USER" 2>/dev/null || true + rm -rf -- "$MESH_DIR" + systemctl daemon-reload + sysctl --system >/dev/null + log "Mesh Hub удалён; monitoring identity оставлена" +} + +main() { + local action="${1:-install}" + require_root + detect_system + case "$action" in + install|install-monitor) install_server_part ;; + hub|install-hub) install_hub ;; + node|install-node) install_node ;; + install-control-hub) install_control_hub ;; + install-control-agent) install_control_agent ;; + control-code) [[ $# -eq 2 ]] || die "Использование: $0 control-code NAME"; create_control_join_code "$2" ;; + control-device-code) [[ $# -eq 2 ]] || die "Использование: $0 control-device-code DEVICE"; create_device_join_code "$2" ;; + hub-code) [[ $# -eq 2 ]] || die "Использование: $0 hub-code NAME"; exec "$HUB_HELPER" node-code "$2" ;; + status) show_status ;; + update) update_installed ;; + rollback) rollback_state ;; + uninstall-monitor) uninstall_monitor ;; + uninstall-node) uninstall_node ;; + uninstall-hub) uninstall_hub ;; + uninstall) die "Укажите роль: uninstall-monitor, uninstall-node или uninstall-hub" ;; + *) die "Использование: $0 {install-monitor|install-hub|install-node|install-control-hub|install-control-agent|control-code NAME|control-device-code DEVICE|hub-code NAME|status|update|rollback|uninstall-monitor|uninstall-node|uninstall-hub}" ;; + esac +} + +main "$@" diff --git a/tests/test-monitor-manager-control-layer.sh b/tests/test-monitor-manager-control-layer.sh new file mode 100644 index 0000000..717c8f7 --- /dev/null +++ b/tests/test-monitor-manager-control-layer.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." +script="ochenstarik-server-monitor-manager.sh" + +[[ -s "$script" ]] +grep -Fq 'readonly SMM_RELEASE_VERSION=' "$script" +grep -Fq 'server-monitor-manager-${runtime}.tar.gz' "$script" +grep -Fq 'sha256sum "$archive"' "$script" +grep -Fq 'SMMCTL1-' "$script" +grep -Fq 'SMMDEV1-' "$script" +grep -Fq 'control-code)' "$script" +grep -Fq 'install-control-hub)' "$script" +grep -Fq 'install-control-agent)' "$script" +grep -Fq 'control-device-code)' "$script" +grep -Fq 'ochenstarik-smm-policy-apply' "$script" +grep -Fq 'NoNewPrivileges=true' "$script" +grep -Fq 'ProtectSystem=strict' "$script" +grep -Fq 'SMM_EnrollToken=${token}' "$script" +grep -Fq 'systemctl enable --now ochenstarik-smm-control.service' "$script" +grep -Fq 'systemctl enable --now ochenstarik-smm-agent.service' "$script" + +if grep -Eq 'CONTROL_JOIN_CODE=.*(ca\.key|control-ca\.pfx)' "$script"; then + echo 'Control join code must never contain the CA private key.' >&2 + exit 1 +fi + +printf 'Server Monitor Manager control-layer installer checks passed.\n' diff --git a/tests/test-monitor-manager-enrollment.sh b/tests/test-monitor-manager-enrollment.sh new file mode 100644 index 0000000..761eb43 --- /dev/null +++ b/tests/test-monitor-manager-enrollment.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." +script="ochenstarik-server-monitor-manager.sh" +temp_dir="$(mktemp -d)" +trap 'rm -rf -- "$temp_dir"' EXIT + +[[ -s "$script" ]] +grep -Fq 'code="SMM2-' "$script" +grep -Fq 'request_code="SMMREQ1-' "$script" +grep -Fq 'SMMACK1-' "$script" +grep -Fq 'expires=$(( $(date +%s) + 600 ))' "$script" +grep -Fq 'mv -- "$token_file" "$consuming_file"' "$script" +grep -Fq 'private_key="$(wg genkey)"' "$script" + +if grep -Fq 'PRIVATE_KEY=%s' "$script"; then + echo 'Enrollment payload must not contain a private WireGuard key.' >&2 + exit 1 +fi + +helper="$temp_dir/ochenstarik-smm-hub" +awk ' + capture && /^EOF$/ { exit } + /cat > .*HUB_HELPER.*< "$helper" +chmod 700 "$helper" + +state="$temp_dir/state" +mock_bin="$temp_dir/bin" +mkdir -p "$state/nodes" "$state/tokens" "$mock_bin" +sed -i "s|^readonly STATE_DIR=.*|readonly STATE_DIR=\"$state\"|" "$helper" +sed -i "s|^readonly WG_CONFIG=.*|readonly WG_CONFIG=\"$temp_dir/smm0.conf\"|" "$helper" +sed -i 's/^require_root() .*/require_root() { :; }/' "$helper" +sed -i 's/ -o root -g root//g' "$helper" + +public_key='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +cat > "$mock_bin/wg" < "$mock_bin/ip" <<'EOF' +#!/usr/bin/env bash +exit 1 +EOF +chmod 700 "$mock_bin/wg" "$mock_bin/ip" + +cat > "$state/hub.conf" <<'EOF' +HUB_ENDPOINT=hub.example.test +WG_PORT=51820 +HUB_ADDRESS=10.77.0.1 +MESH_CIDR=10.77.0.0/24 +EOF +printf 'test-hub-private-key\n' > "$state/hub.key" +: > "$state/links" + +output="$(PATH="$mock_bin:$PATH" "$helper" node-code test-node)" +join_code="$(printf '%s\n' "$output" | grep '^SMM2-')" +[[ -n "$join_code" ]] + +decode_base64url() { + local data="$1" + data="${data//-/+}" + data="${data//_/\/}" + case $(( ${#data} % 4 )) in + 2) data+='==' ;; + 3) data+='=' ;; + esac + printf '%s' "$data" | base64 -d +} + +payload="$(decode_base64url "${join_code#SMM2-}")" +token="$(printf '%s\n' "$payload" | awk -F= '$1 == "TOKEN" { print $2 }')" +name="$(printf '%s\n' "$payload" | awk -F= '$1 == "NAME" { print $2 }')" +address="$(printf '%s\n' "$payload" | awk -F= '$1 == "ADDRESS" { print $2 }')" +[[ "$name" == test-node ]] +[[ "$address" == 10.77.0.2/32 ]] +[[ "$token" =~ ^[a-f0-9]{64}$ ]] +[[ "$payload" != *PRIVATE_KEY* ]] + +request_payload="$(printf 'VERSION=1\nTOKEN=%s\nNAME=%s\nADDRESS=%s\nPUBLIC_KEY=%s\n' \ + "$token" "$name" "$address" "$public_key")" +request_code="SMMREQ1-$(printf '%s' "$request_payload" | base64 -w0 | tr '+/' '-_' | tr -d '=')" +enroll_output="$(PATH="$mock_bin:$PATH" SMM_ENROLL_REQUEST="$request_code" "$helper" node-enroll)" +grep -Eq '^SMMACK1-[a-f0-9]{64}$' <<< "$enroll_output" +grep -Fqx 'NAME=test-node' "$state/nodes/test-node.node" +grep -Fqx "PUBLIC_KEY=$public_key" "$state/nodes/test-node.node" + +if PATH="$mock_bin:$PATH" SMM_ENROLL_REQUEST="$request_code" "$helper" node-enroll >/dev/null 2>&1; then + echo 'Enrollment token was accepted more than once.' >&2 + exit 1 +fi + +printf 'Server Monitor Manager enrollment checks passed.\n' diff --git a/tests/test-monitor-manager-lifecycle.sh b/tests/test-monitor-manager-lifecycle.sh new file mode 100644 index 0000000..c663322 --- /dev/null +++ b/tests/test-monitor-manager-lifecycle.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." +script="ochenstarik-server-monitor-manager.sh" + +[[ -s "$script" ]] +grep -Fq 'sudo' "$script" +grep -Fq 'iputils-ping' "$script" +grep -Fq 'backup_state()' "$script" +grep -Fq 'rollback_state()' "$script" +grep -Fq 'update_installed()' "$script" +grep -Fq 'uninstall_monitor()' "$script" +grep -Fq 'uninstall_node()' "$script" +grep -Fq 'uninstall_hub()' "$script" +grep -Fq '/var/backups/${APP_NAME}' "$script" +grep -Fq 'systemctl disable --now ochenstarik-smm-firewall.timer' "$script" +grep -Fq 'nft delete table inet ochenstarik_smm' "$script" +grep -Fq 'sysctl --system' "$script" +grep -Fq 'uninstall) die "Укажите роль:' "$script" + +printf 'Server Monitor Manager lifecycle checks passed.\n' diff --git a/tests/test-monitor-manager-links.sh b/tests/test-monitor-manager-links.sh new file mode 100644 index 0000000..5a1c411 --- /dev/null +++ b/tests/test-monitor-manager-links.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." +script="ochenstarik-server-monitor-manager.sh" +temp_dir="$(mktemp -d)" +trap 'rm -rf -- "$temp_dir"' EXIT + +helper="$temp_dir/ochenstarik-smm-hub" +awk ' + capture && /^EOF$/ { exit } + /cat > .*HUB_HELPER.*< "$helper" +chmod 700 "$helper" + +state="$temp_dir/state" +mock_bin="$temp_dir/bin" +nft_capture="$temp_dir/nft.conf" +mkdir -p "$state/nodes" "$state/tokens" "$mock_bin" +sed -i "s|^readonly STATE_DIR=.*|readonly STATE_DIR=\"$state\"|" "$helper" +sed -i "s|^readonly WG_CONFIG=.*|readonly WG_CONFIG=\"$temp_dir/smm0.conf\"|" "$helper" +sed -i 's/^require_root() .*/require_root() { :; }/' "$helper" +sed -i 's/ -o root -g root//g' "$helper" + +cat > "$mock_bin/nft" <<'EOF' +#!/usr/bin/env bash +previous='' +for argument in "$@"; do + if [[ "$previous" == -f ]]; then + cp -- "$argument" "$NFT_CAPTURE" + fi + previous="$argument" +done +exit 0 +EOF +chmod 700 "$mock_bin/nft" + +cat > "$state/hub.conf" <<'EOF' +HUB_ENDPOINT=hub.example.test +WG_PORT=51820 +HUB_ADDRESS=10.77.0.1 +MESH_CIDR=10.77.0.0/24 +EOF +printf 'test-hub-private-key\n' > "$state/hub.key" +cat > "$state/nodes/ai-agent.node" <<'EOF' +NAME=ai-agent +ADDRESS=10.77.0.2 +PUBLIC_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +EOF +cat > "$state/nodes/home.node" <<'EOF' +NAME=home +ADDRESS=10.77.0.3 +PUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +EOF +printf 'legacy-policy home\n' > "$state/links" + +PATH="$mock_bin:$PATH" NFT_CAPTURE="$nft_capture" \ + "$helper" link-connect ai-agent home tcp 2222 120 + +read -r source target cidr protocol port expires version < "$state/links" +[[ "$source" == ai-agent ]] +[[ "$target" == home ]] +[[ "$cidr" == 10.77.0.3/32 ]] +[[ "$protocol" == tcp ]] +[[ "$port" == 2222 ]] +(( expires > $(date +%s) )) +[[ "$version" =~ ^[0-9]+$ ]] +grep -Fq 'ip saddr 10.77.0.2 ip daddr 10.77.0.3/32 tcp dport 2222 accept' "$nft_capture" +grep -Fq '"state":"Connecting"' "$state/audit.jsonl" +grep -Fq '"state":"Active"' "$state/audit.jsonl" + +if PATH="$mock_bin:$PATH" NFT_CAPTURE="$nft_capture" \ + "$helper" link-connect ai-agent home tcp 70000 120 >/dev/null 2>&1; then + echo 'Invalid port was accepted.' >&2 + exit 1 +fi + +printf 'ai-agent home 10.77.0.3/32 udp 53 1 99\n' >> "$state/links" +PATH="$mock_bin:$PATH" NFT_CAPTURE="$nft_capture" "$helper" firewall-restore +[[ "$(wc -l < "$state/links")" -eq 1 ]] + +PATH="$mock_bin:$PATH" NFT_CAPTURE="$nft_capture" \ + "$helper" link-disconnect ai-agent home tcp 2222 +[[ ! -s "$state/links" ]] +grep -Fq '"state":"Expired"' "$state/audit.jsonl" +grep -Fq '"state":"Disconnecting"' "$state/audit.jsonl" +grep -Fq '"state":"Disabled"' "$state/audit.jsonl" +if grep -Fq 'dport 2222 accept' "$nft_capture"; then + echo 'Disconnected policy remained in nftables.' >&2 + exit 1 +fi + +grep -Fq 'OnUnitActiveSec=1min' "$script" +printf 'Server Monitor Manager Link policy checks passed.\n' diff --git a/tests/test-monitor-manager-metrics.sh b/tests/test-monitor-manager-metrics.sh new file mode 100644 index 0000000..e7901f0 --- /dev/null +++ b/tests/test-monitor-manager-metrics.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." +script="ochenstarik-server-monitor-manager.sh" + +for key in \ + SWAP_TOTAL_KB SWAP_FREE_KB \ + DISK_INODES_TOTAL DISK_INODES_FREE \ + NETWORK_RX_BYTES NETWORK_TX_BYTES \ + SYSTEMD_SSH SYSTEMD_WIREGUARD; do + grep -Fq "$key" "$script" +done + +printf 'Server Monitor Manager extended metrics checks passed.\n'