From 9289eb8a13b2e6a5b177be982c9f20a68ba45058 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Wed, 15 Jul 2026 13:27:49 +0700 Subject: [PATCH 01/20] Add SSH monitoring server installer --- ochenstarik-server-monitor-manager.sh | 162 ++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 ochenstarik-server-monitor-manager.sh diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh new file mode 100644 index 0000000..acd8289 --- /dev/null +++ b/ochenstarik-server-monitor-manager.sh @@ -0,0 +1,162 @@ +#!/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" + +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() { + printf '\nВ Windows-приложении нажмите «SSH-ключ» → «Копировать».\n' + read -rp 'Вставьте публичный ключ: ' PUBLIC_KEY + PUBLIC_KEY="${PUBLIC_KEY//$'\r'/}" + [[ "$PUBLIC_KEY" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/]+={0,3}([[:space:]].*)?$ ]] \ + || die "Ожидается публичный ключ формата ssh-ed25519 AAAA..." +} + +install_dependencies() { + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ca-certificates openssh-server openssh-client coreutils gawk + systemctl enable --now ssh +} + +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 + +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)" +df -Pk / | awk 'NR == 2 { + printf "DISK_TOTAL_KB=%s\nDISK_AVAILABLE_KB=%s\n", $2, $4 +}' +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 отключена аутентификация по публичному ключу" +} + +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}" + printf 'Команда: %s\n' "$MONITOR_COMMAND" + if [[ -x "$MONITOR_COMMAND" ]]; then + runuser -u "$MONITOR_USER" -- "$MONITOR_COMMAND" + else + warn "Серверная часть ещё не установлена" + fi +} + +install_server_part() { + read_public_key + install_dependencies + verify_public_key + create_monitor_command + create_monitor_user + verify_sshd + systemctl reload ssh + log "Серверная часть установлена" + log "Входящий порт не изменялся, новые правила UFW не создавались" + show_status +} + +uninstall_server_part() { + local answer + read -rp 'Удалить пользователя мониторинга и forced-command? [y/N]: ' answer + [[ "$answer" =~ ^[Yy]$ ]] || { log "Отменено"; return 0; } + rm -f -- "$MONITOR_COMMAND" + if getent passwd "$MONITOR_USER" >/dev/null; then + userdel --remove "$MONITOR_USER" 2>/dev/null || userdel "$MONITOR_USER" + fi + log "Серверная часть удалена" +} + +main() { + local action="${1:-install}" + require_root + detect_system + case "$action" in + install) install_server_part ;; + status) show_status ;; + uninstall) uninstall_server_part ;; + *) die "Использование: $0 [install|status|uninstall]" ;; + esac +} + +main "$@" -- 2.45.2 From 29afdbeb6d75a8aecf968381e78290dc1487206b Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Wed, 15 Jul 2026 13:39:53 +0700 Subject: [PATCH 02/20] Support one-command monitor installation --- ochenstarik-server-monitor-manager.sh | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index acd8289..eea5eac 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -27,8 +27,17 @@ detect_system() { } read_public_key() { - printf '\nВ Windows-приложении нажмите «SSH-ключ» → «Копировать».\n' - read -rp 'Вставьте публичный ключ: ' 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..." @@ -138,7 +147,11 @@ install_server_part() { uninstall_server_part() { local answer - read -rp 'Удалить пользователя мониторинга и forced-command? [y/N]: ' answer + if [[ -r /dev/tty ]]; then + IFS= read -r -p 'Удалить пользователя мониторинга и forced-command? [y/N]: ' answer < /dev/tty + else + die "Для удаления требуется интерактивный терминал" + fi [[ "$answer" =~ ^[Yy]$ ]] || { log "Отменено"; return 0; } rm -f -- "$MONITOR_COMMAND" if getent passwd "$MONITOR_USER" >/dev/null; then -- 2.45.2 From d50d28369a55ee9d6e3cceeec82fe095ed3726d7 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Wed, 15 Jul 2026 23:36:25 +0700 Subject: [PATCH 03/20] Add managed WireGuard mesh installer --- README.md | 48 +++ ochenstarik-server-monitor-manager.sh | 518 +++++++++++++++++++++++++- 2 files changed, 553 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3bf9567..3d5fb77 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ | `ochenstarik-server-vpn-5.sh` | Устанавливает Xray и направляет системный трафик через VLESS + REALITY | Да; зависимости устанавливаются автоматически | | `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-monitor-manager.sh` | Устанавливает главный WireGuard Hub или подключаемый узел Server Monitor Manager | Да; Hub нужен белый IP, вторичному узлу — только исходящий доступ | Файлы `*.env.example` являются только примерами. Не записывайте настоящие пароли, токены и приватные SSH-ключи в Git. @@ -65,6 +66,53 @@ chmod 700 ./*.sh bash -n ./ИМЯ-СКРИПТА.sh ``` +## Server Monitor Manager: своя сеть без Tailscale + +Файл `ochenstarik-server-monitor-manager.sh` устанавливает лёгкую сеть WireGuard со звездной топологией. Главный сервер с белым IP становится Hub, а домашний и другие серверы подключаются к нему исходящими соединениями. Белый IP на вторичных узлах не нужен. + +На главном сервере откройте входящий UDP-порт WireGuard (по умолчанию `51820`) в панели хостинга и запустите: + +```bash +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/agent/server-monitor-installer/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 +``` + +После установки создайте отдельный секретный код конфигурации для каждого узла: + +```bash +sudo ochenstarik-smm node-code hermes +sudo ochenstarik-smm node-code home +sudo ochenstarik-smm node-code server2 +``` + +На соответствующем вторичном сервере скачайте тот же файл и выполните: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh node +``` + +Установщик попросит публичный SSH-ключ приложения и код, созданный для этого узла. Код содержит приватный ключ WireGuard узла: передавайте его только в защищённый терминал, используйте для одного сервера и создайте новый узел, если код стал известен посторонним. + +Связи направленные и управляются на Hub. Например, разрешить Hermes доступ к домашнему серверу и ко второму серверу, а затем отключить только второй маршрут: + +```bash +sudo ochenstarik-smm link-connect hermes home +sudo ochenstarik-smm link-connect hermes server2 +sudo ochenstarik-smm link-disconnect hermes server2 +``` + +Проверка состояния: + +```bash +sudo ochenstarik-smm nodes +sudo ochenstarik-smm links +sudo ochenstarik-smm status +``` + +Hub маршрутизирует только явно разрешённые пары `источник → цель`; обратное направление включается отдельно. Вторичные серверы не соединяются друг с другом напрямую и не требуют Tailscale или другого внешнего сервиса. + ## 1. Часовой пояс и настройка swap Файл: `ochenstarik-server-1.sh`. diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index eea5eac..59e531d 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -6,6 +6,18 @@ 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 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 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" log() { printf '[+] %s\n' "$*"; } warn() { printf '[!] %s\n' "$*" >&2; } @@ -43,13 +55,19 @@ read_public_key() { || die "Ожидается публичный ключ формата ssh-ed25519 AAAA..." } -install_dependencies() { +install_monitor_dependencies() { apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y \ ca-certificates openssh-server openssh-client coreutils gawk systemctl enable --now ssh } +install_mesh_dependencies() { + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + wireguard-tools nftables iproute2 jq openssl +} + verify_public_key() { local key_file key_file="$(mktemp)" @@ -70,6 +88,43 @@ create_monitor_command() { 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 extra <<< "$original_command" + [[ "$prefix" == mesh && "$action" == connect && -n "$source" && -n "$target" && -z "${extra:-}" ]] \ + || { echo "Некорректная команда" >&2; exit 2; } + exec sudo -n "$HUB_HELPER" link-connect "$source" "$target" + ;; + "mesh disconnect "*) + [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } + read -r prefix action source target extra <<< "$original_command" + [[ "$prefix" == mesh && "$action" == disconnect && -n "$source" && -n "$target" && -z "${extra:-}" ]] \ + || { echo "Некорректная команда" >&2; exit 2; } + exec sudo -n "$HUB_HELPER" link-disconnect "$source" "$target" + ;; + *) + echo "Разрешены только metrics и команды mesh" >&2 + exit 2 + ;; +esac + read_mem_value() { awk -v key="$1" '$1 == key ":" { print $2; exit }' /proc/meminfo } @@ -117,14 +172,454 @@ verify_sshd() { || 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 HUB_CONFIG="${STATE_DIR}/hub.conf" +readonly HUB_PRIVATE_KEY="${STATE_DIR}/hub.key" +readonly LINKS_FILE="${STATE_DIR}/links" +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")" ]]; } + +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; 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 '=' +} + +node_code() { + local name="$1" private_key public_key address endpoint port payload code file + valid_name "$name" || die "Имя: строчные латинские буквы, цифры и дефис, максимум 32 символа" + node_exists "$name" && die "Узел $name уже существует" + private_key="$(wg genkey)" + public_key="$(printf '%s' "$private_key" | wg pubkey)" + address="$(next_address)" + endpoint="$(config_value HUB_ENDPOINT "$HUB_CONFIG")" + port="$(config_value WG_PORT "$HUB_CONFIG")" + file="$(node_file "$name")" + umask 077 + { + printf 'NAME=%s\n' "$name" + printf 'ADDRESS=%s\n' "$address" + printf 'PUBLIC_KEY=%s\n' "$public_key" + } > "$file" + render_wireguard_config + payload="$(printf 'VERSION=1\nNAME=%s\nADDRESS=%s/32\nPRIVATE_KEY=%s\nHUB_PUBLIC_KEY=%s\nENDPOINT=%s:%s\nALLOWED_IPS=10.77.0.0/24\n' \ + "$name" "$address" "$private_key" "$(wg pubkey < "$HUB_PRIVATE_KEY")" "$endpoint" "$port")" + code="SMM1-$(printf '%s' "$payload" | base64url_encode)" + unset private_key payload + printf '\nСекретный код конфигурации для узла %s:\n%s\n\n' "$name" "$code" + printf 'Код содержит приватный ключ узла. Используйте его только на одном целевом сервере и не сохраняйте в чатах или логах.\n' +} + +restore_firewall() { + local tmp source target source_ip target_ip first=true + tmp="$(mktemp)" + nft delete table inet "$NFT_TABLE" >/dev/null 2>&1 || true + { + printf 'table inet %s {\n' "$NFT_TABLE" + printf ' set links {\n type ipv4_addr . ipv4_addr\n' + if [[ -s "$LINKS_FILE" ]]; then + printf ' elements = { ' + while read -r source target; do + [[ -n "$source" && -n "$target" ]] || continue + node_exists "$source" && node_exists "$target" || continue + source_ip="$(node_value "$source" ADDRESS)" + target_ip="$(node_value "$target" ADDRESS)" + [[ "$first" == true ]] || printf ', ' + printf '%s . %s' "$source_ip" "$target_ip" + first=false + done < "$LINKS_FILE" + printf ' }\n' + fi + printf ' }\n' + 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" + printf ' iifname "%s" oifname "%s" ip saddr . ip daddr @links accept\n' "$WG_INTERFACE" "$WG_INTERFACE" + printf ' iifname "%s" oifname "%s" drop\n' "$WG_INTERFACE" "$WG_INTERFACE" + printf ' }\n}\n' + } > "$tmp" + nft -f "$tmp" + rm -f -- "$tmp" +} + +link_connect() { + local source="$1" target="$2" tmp + valid_name "$source" && valid_name "$target" || die "Некорректное имя узла" + [[ "$source" != "$target" ]] || die "Источник и назначение совпадают" + node_exists "$source" || die "Узел $source не найден" + node_exists "$target" || die "Узел $target не найден" + if grep -Fxq "$source $target" "$LINKS_FILE" 2>/dev/null; then + log "Связь $source → $target уже включена" + return + fi + tmp="$(mktemp)" + { [[ ! -f "$LINKS_FILE" ]] || cat "$LINKS_FILE"; printf '%s %s\n' "$source" "$target"; } \ + | sort -u > "$tmp" + install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" + rm -f -- "$tmp" + restore_firewall + log "Связь $source → $target включена" +} + +link_disconnect() { + local source="$1" target="$2" tmp + valid_name "$source" && valid_name "$target" || die "Некорректное имя узла" + tmp="$(mktemp)" + if [[ -f "$LINKS_FILE" ]]; then + awk -v source="$source" -v target="$target" '!($1 == source && $2 == target)' "$LINKS_FILE" > "$tmp" + fi + install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" + rm -f -- "$tmp" + restore_firewall + log "Связь $source → $target отключена" +} + +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 + [[ -f "$LINKS_FILE" ]] || return 0 + while read -r source target; do + [[ -n "$source" && -n "$target" ]] || continue + printf 'LINK=%s|%s|enabled\n' "$source" "$target" + 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-remove) [[ $# -eq 2 ]] || die "Использование: $0 node-remove NAME"; remove_node "$2" ;; + nodes) list_nodes ;; + links) list_links ;; + link-connect) [[ $# -eq 3 ]] || die "Использование: $0 link-connect SOURCE TARGET"; link_connect "$2" "$3" ;; + link-disconnect) [[ $# -eq 3 ]] || die "Использование: $0 link-disconnect SOURCE TARGET"; link_disconnect "$2" "$3" ;; + render) render_wireguard_config ;; + firewall-restore) restore_firewall ;; + status) status ;; + *) die "Команды: node-code, 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/sudoers.d/ochenstarik-smm-hub </dev/null \ + || die "Некорректный sudoers-файл Mesh Hub" +} + +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" +} + +install_hub() { + install_monitoring + install_mesh_dependencies + read_hub_endpoint + install -d -m 0700 -o root -g root "$MESH_DIR" "$NODES_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" + 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 hermes" + log "Включить связь: sudo ochenstarik-smm link-connect hermes home" +} + +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 }' +} + +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 'Вставьте секретный код SMM1: ' JOIN_CODE < /dev/tty + printf '\n' + else + die "Передайте код через SMM_JOIN_CODE" + fi + JOIN_CODE="${JOIN_CODE//$'\r'/}" + [[ "$JOIN_CODE" == SMM1-* ]] || die "Ожидается код формата SMM1-..." +} + +install_node_mesh() { + local payload version name address private_key hub_public_key endpoint allowed_ips derived_public tmp ssh_port + install_mesh_dependencies + read_join_code + payload="$(base64url_decode "${JOIN_CODE#SMM1-}")" || die "Не удалось декодировать код" + version="$(payload_value "$payload" VERSION)" + name="$(payload_value "$payload" NAME)" + address="$(payload_value "$payload" ADDRESS)" + private_key="$(payload_value "$payload" PRIVATE_KEY)" + hub_public_key="$(payload_value "$payload" HUB_PUBLIC_KEY)" + endpoint="$(payload_value "$payload" ENDPOINT)" + allowed_ips="$(payload_value "$payload" ALLOWED_IPS)" + [[ "$version" == 1 ]] || 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-подсеть" + derived_public="$(printf '%s' "$private_key" | wg pubkey 2>/dev/null)" \ + || die "Некорректный приватный WireGuard-ключ" + [[ "$(printf '%s' "$hub_public_key" | base64 -d 2>/dev/null | wc -c)" -eq 32 ]] \ + || die "Некорректный публичный ключ Hub" + 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" "$derived_public" > "$MESH_DIR/node.conf" + chmod 0600 "$MESH_DIR/node.conf" + unset private_key payload JOIN_CODE SMM_JOIN_CODE + 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 "Вторичный сервер установлен" +} + 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 'Пользователь мониторинга: %s\n' "$MONITOR_USER" printf 'SSH-порт: %s\n' "${ssh_port:-unknown}" - printf 'Команда: %s\n' "$MONITOR_COMMAND" + 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 @@ -133,14 +628,8 @@ show_status() { } install_server_part() { - read_public_key - install_dependencies - verify_public_key - create_monitor_command - create_monitor_user - verify_sshd - systemctl reload ssh - log "Серверная часть установлена" + install_monitoring + log "Серверная часть мониторинга установлена" log "Входящий порт не изменялся, новые правила UFW не создавались" show_status } @@ -157,7 +646,7 @@ uninstall_server_part() { if getent passwd "$MONITOR_USER" >/dev/null; then userdel --remove "$MONITOR_USER" 2>/dev/null || userdel "$MONITOR_USER" fi - log "Серверная часть удалена" + log "Серверная часть мониторинга удалена" } main() { @@ -166,9 +655,12 @@ main() { detect_system case "$action" in install) install_server_part ;; + hub) install_hub ;; + node) install_node ;; + hub-code) [[ $# -eq 2 ]] || die "Использование: $0 hub-code NAME"; exec "$HUB_HELPER" node-code "$2" ;; status) show_status ;; uninstall) uninstall_server_part ;; - *) die "Использование: $0 [install|status|uninstall]" ;; + *) die "Использование: $0 {install|hub|node|hub-code NAME|status|uninstall}" ;; esac } -- 2.45.2 From 5bce599d2f4171be99d6a1377e15aaac9ca57536 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Wed, 15 Jul 2026 23:42:28 +0700 Subject: [PATCH 04/20] Use generic AI agent examples --- README.md | 10 +++++----- ochenstarik-server-monitor-manager.sh | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3d5fb77..fc28f8b 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ sudo ./ochenstarik-server-monitor-manager.sh hub После установки создайте отдельный секретный код конфигурации для каждого узла: ```bash -sudo ochenstarik-smm node-code hermes +sudo ochenstarik-smm node-code ai-agent sudo ochenstarik-smm node-code home sudo ochenstarik-smm node-code server2 ``` @@ -95,12 +95,12 @@ sudo ./ochenstarik-server-monitor-manager.sh node Установщик попросит публичный SSH-ключ приложения и код, созданный для этого узла. Код содержит приватный ключ WireGuard узла: передавайте его только в защищённый терминал, используйте для одного сервера и создайте новый узел, если код стал известен посторонним. -Связи направленные и управляются на Hub. Например, разрешить Hermes доступ к домашнему серверу и ко второму серверу, а затем отключить только второй маршрут: +Связи направленные и управляются на Hub. Например, разрешить AI-агенту доступ к домашнему серверу и ко второму серверу, а затем отключить только второй маршрут: ```bash -sudo ochenstarik-smm link-connect hermes home -sudo ochenstarik-smm link-connect hermes server2 -sudo ochenstarik-smm link-disconnect hermes server2 +sudo ochenstarik-smm link-connect ai-agent home +sudo ochenstarik-smm link-connect ai-agent server2 +sudo ochenstarik-smm link-disconnect ai-agent server2 ``` Проверка состояния: diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 59e531d..0f965ea 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -517,8 +517,8 @@ EOF 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 hermes" - log "Включить связь: sudo ochenstarik-smm link-connect hermes home" + log "Создать код узла: sudo ochenstarik-smm node-code ai-agent" + log "Включить связь: sudo ochenstarik-smm link-connect ai-agent home" } base64url_decode() { -- 2.45.2 From 66b9c3730e349279a904f5423257419faf278a3f Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 18:31:11 +0700 Subject: [PATCH 05/20] Secure WireGuard node enrollment --- .github/workflows/bash-syntax.yml | 3 + README.md | 4 +- docs/readme/README.ru.md | 4 +- ochenstarik-server-monitor-manager.sh | 176 +++++++++++++++++++---- tests/test-monitor-manager-enrollment.sh | 99 +++++++++++++ 5 files changed, 258 insertions(+), 28 deletions(-) create mode 100644 tests/test-monitor-manager-enrollment.sh diff --git a/.github/workflows/bash-syntax.yml b/.github/workflows/bash-syntax.yml index f1d0f45..70458bd 100644 --- a/.github/workflows/bash-syntax.yml +++ b/.github/workflows/bash-syntax.yml @@ -43,3 +43,6 @@ 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 diff --git a/README.md b/README.md index a001180..1be429d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ bash -n ochenstarik-server-monitor-manager.sh sudo ./ochenstarik-server-monitor-manager.sh hub ``` -Create a separate configuration code for each Node on the Hub, then run the same installer with `node` on the matching server: +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 @@ -109,7 +109,7 @@ sudo ochenstarik-smm nodes sudo ochenstarik-smm links ``` -The current configuration code contains the Node private WireGuard key and is intended only for early testing. Do not store it in Git or messages. Local Node key generation and expiring one-time enrollment are tracked before production use. +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. ## Installation flow diff --git a/docs/readme/README.ru.md b/docs/readme/README.ru.md index c0052d7..3e7c7fb 100644 --- a/docs/readme/README.ru.md +++ b/docs/readme/README.ru.md @@ -79,7 +79,7 @@ bash -n ochenstarik-server-monitor-manager.sh sudo ./ochenstarik-server-monitor-manager.sh hub ``` -На Hub создайте отдельный код для каждого Node, после чего на соответствующем сервере запустите тот же файл в режиме `node`: +На Hub создайте отдельный enrollment-код на 10 минут для каждого Node, после чего на соответствующем сервере запустите тот же файл в режиме `node`: ```bash sudo ochenstarik-smm node-code ai-agent @@ -96,7 +96,7 @@ sudo ochenstarik-smm nodes sudo ochenstarik-smm links ``` -Текущий код конфигурации содержит приватный WireGuard-ключ Node и предназначен только для раннего тестирования. Не сохраняйте его в Git или сообщениях. Локальная генерация ключа и одноразовая регистрация с ограниченным сроком будут добавлены до production-использования. +Установщик Node создаст приватный WireGuard-ключ локально и покажет запрос `SMMREQ1`. Во втором терминале выполните на Hub `sudo ochenstarik-smm node-enroll`, вставьте запрос по скрытому приглашению и верните полученный код `SMMACK1` в установщик Node. Enrollment-код действует 10 минут и погашается при первой успешной регистрации. ## Требования diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 0f965ea..e2280f3 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -8,6 +8,7 @@ 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" @@ -191,6 +192,7 @@ 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" @@ -241,7 +243,7 @@ next_address() { for host in $(seq 2 254); do address="10.77.0.${host}" used=false - for file in "$NODES_DIR"/*.node; do + for file in "$NODES_DIR"/*.node "$TOKENS_DIR"/*.token*; do [[ -e "$file" ]] || continue if [[ "$(config_value ADDRESS "$file")" == "$address" ]]; then used=true @@ -257,29 +259,119 @@ 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" private_key public_key address endpoint port payload code file + local name="$1" address endpoint port payload code token token_hash expires file valid_name "$name" || die "Имя: строчные латинские буквы, цифры и дефис, максимум 32 символа" node_exists "$name" && die "Узел $name уже существует" - private_key="$(wg genkey)" - public_key="$(printf '%s' "$private_key" | wg pubkey)" + 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")" - file="$(node_file "$name")" + 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 'PUBLIC_KEY=%s\n' "$public_key" + 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 - payload="$(printf 'VERSION=1\nNAME=%s\nADDRESS=%s/32\nPRIVATE_KEY=%s\nHUB_PUBLIC_KEY=%s\nENDPOINT=%s:%s\nALLOWED_IPS=10.77.0.0/24\n' \ - "$name" "$address" "$private_key" "$(wg pubkey < "$HUB_PRIVATE_KEY")" "$endpoint" "$port")" - code="SMM1-$(printf '%s' "$payload" | base64url_encode)" - unset private_key payload - printf '\nСекретный код конфигурации для узла %s:\n%s\n\n' "$name" "$code" - printf 'Код содержит приватный ключ узла. Используйте его только на одном целевом сервере и не сохраняйте в чатах или логах.\n' + 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" } restore_firewall() { @@ -409,6 +501,7 @@ main() { [[ -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 ;; @@ -417,7 +510,7 @@ main() { render) render_wireguard_config ;; firewall-restore) restore_firewall ;; status) status ;; - *) die "Команды: node-code, node-remove, nodes, links, link-connect, link-disconnect, status" ;; + *) die "Команды: node-code, node-enroll, node-remove, nodes, links, link-connect, link-disconnect, status" ;; esac } @@ -487,7 +580,7 @@ install_hub() { install_monitoring install_mesh_dependencies read_hub_endpoint - install -d -m 0700 -o root -g root "$MESH_DIR" "$NODES_DIR" + 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" @@ -521,6 +614,10 @@ EOF log "Включить связь: sudo ochenstarik-smm link-connect ai-agent home" } +base64url_encode() { + base64 -w0 | tr '+/' '-_' | tr -d '=' +} + base64url_decode() { local data="$1" remainder data="${data//-/+}" @@ -543,37 +640,68 @@ read_join_code() { JOIN_CODE="$SMM_JOIN_CODE" elif [[ -r /dev/tty ]]; then printf '\nНа главном сервере выполните: sudo ochenstarik-smm node-code ИМЯ\n' - IFS= read -r -s -p 'Вставьте секретный код SMM1: ' JOIN_CODE < /dev/tty + 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" == SMM1-* ]] || die "Ожидается код формата SMM1-..." + [[ "$JOIN_CODE" == SMM2-* ]] || die "Ожидается код формата SMM2-..." } install_node_mesh() { - local payload version name address private_key hub_public_key endpoint allowed_ips derived_public tmp ssh_port + 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#SMM1-}")" || die "Не удалось декодировать код" + payload="$(base64url_decode "${JOIN_CODE#SMM2-}")" || die "Не удалось декодировать код" version="$(payload_value "$payload" VERSION)" name="$(payload_value "$payload" NAME)" address="$(payload_value "$payload" ADDRESS)" - private_key="$(payload_value "$payload" PRIVATE_KEY)" + 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" == 1 ]] || die "Неподдерживаемая версия кода" + [[ "$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-подсеть" - derived_public="$(printf '%s' "$private_key" | wg pubkey 2>/dev/null)" \ - || die "Некорректный приватный WireGuard-ключ" + [[ "$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)" { @@ -584,9 +712,9 @@ install_node_mesh() { 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" "$derived_public" > "$MESH_DIR/node.conf" + "$name" "$address" "$public_key" > "$MESH_DIR/node.conf" chmod 0600 "$MESH_DIR/node.conf" - unset private_key payload JOIN_CODE SMM_JOIN_CODE + 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 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' -- 2.45.2 From 78e650a35b69707b38f8386d11f11fec7b201228 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 18:43:49 +0700 Subject: [PATCH 06/20] Restrict mesh links by protocol port and TTL --- .github/workflows/bash-syntax.yml | 3 + README.md | 4 +- docs/readme/README.ru.md | 4 +- ochenstarik-server-monitor-manager.sh | 130 +++++++++++++++++--------- tests/test-monitor-manager-links.sh | 89 ++++++++++++++++++ 5 files changed, 183 insertions(+), 47 deletions(-) create mode 100644 tests/test-monitor-manager-links.sh diff --git a/.github/workflows/bash-syntax.yml b/.github/workflows/bash-syntax.yml index 70458bd..306e900 100644 --- a/.github/workflows/bash-syntax.yml +++ b/.github/workflows/bash-syntax.yml @@ -46,3 +46,6 @@ jobs: - 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 diff --git a/README.md b/README.md index 1be429d..042e5e6 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,8 @@ 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 -sudo ochenstarik-smm link-disconnect ai-agent home +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 ``` diff --git a/docs/readme/README.ru.md b/docs/readme/README.ru.md index 3e7c7fb..549ff84 100644 --- a/docs/readme/README.ru.md +++ b/docs/readme/README.ru.md @@ -90,8 +90,8 @@ sudo ./ochenstarik-server-monitor-manager.sh node Links являются направленными: ```bash -sudo ochenstarik-smm link-connect ai-agent home -sudo ochenstarik-smm link-disconnect ai-agent home +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 ``` diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index e2280f3..784ebaa 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -108,17 +108,17 @@ case "$original_command" in ;; "mesh connect "*) [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } - read -r prefix action source target extra <<< "$original_command" - [[ "$prefix" == mesh && "$action" == connect && -n "$source" && -n "$target" && -z "${extra:-}" ]] \ + 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" + 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 extra <<< "$original_command" - [[ "$prefix" == mesh && "$action" == disconnect && -n "$source" && -n "$target" && -z "${extra:-}" ]] \ + 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" + exec sudo -n "$HUB_HELPER" link-disconnect "$source" "$target" "$protocol" "$port" ;; *) echo "Разрешены только metrics и команды mesh" >&2 @@ -374,68 +374,95 @@ node_enroll() { printf '\nNode зарегистрирован. Верните этот код в установщик Node:\nSMMACK1-%s\n\n' "$ack" } -restore_firewall() { - local tmp source target source_ip target_ip first=true +prune_links() { + local tmp source target cidr protocol port expires now target_ip + tmp="$(mktemp)" + now="$(date +%s)" + if [[ -f "$LINKS_FILE" ]]; then + while read -r source target cidr protocol port expires 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 + (( expires == 0 || expires > now )) || continue + printf '%s %s %s %s %s %s\n' "$source" "$target" "$cidr" "$protocol" "$port" "$expires" >> "$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 source target cidr protocol port expires source_ip + prune_links tmp="$(mktemp)" - nft delete table inet "$NFT_TABLE" >/dev/null 2>&1 || true { printf 'table inet %s {\n' "$NFT_TABLE" - printf ' set links {\n type ipv4_addr . ipv4_addr\n' - if [[ -s "$LINKS_FILE" ]]; then - printf ' elements = { ' - while read -r source target; do - [[ -n "$source" && -n "$target" ]] || continue - node_exists "$source" && node_exists "$target" || continue - source_ip="$(node_value "$source" ADDRESS)" - target_ip="$(node_value "$target" ADDRESS)" - [[ "$first" == true ]] || printf ', ' - printf '%s . %s' "$source_ip" "$target_ip" - first=false - done < "$LINKS_FILE" - printf ' }\n' - fi - printf ' }\n' 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" - printf ' iifname "%s" oifname "%s" ip saddr . ip daddr @links accept\n' "$WG_INTERFACE" "$WG_INTERFACE" + while read -r source target cidr protocol port expires; 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' } > "$tmp" + nft --check -f "$tmp" + nft delete table inet "$NFT_TABLE" >/dev/null 2>&1 || true nft -f "$tmp" rm -f -- "$tmp" } link_connect() { - local source="$1" target="$2" tmp + local source="$1" target="$2" protocol="$3" port="$4" ttl_minutes="$5" + local target_ip cidr expires tmp valid_name "$source" && valid_name "$target" || die "Некорректное имя узла" [[ "$source" != "$target" ]] || die "Источник и назначение совпадают" node_exists "$source" || die "Узел $source не найден" node_exists "$target" || die "Узел $target не найден" - if grep -Fxq "$source $target" "$LINKS_FILE" 2>/dev/null; then - log "Связь $source → $target уже включена" - return - fi + [[ "$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 )) tmp="$(mktemp)" - { [[ ! -f "$LINKS_FILE" ]] || cat "$LINKS_FILE"; printf '%s %s\n' "$source" "$target"; } \ - | sort -u > "$tmp" + 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\n' "$source" "$target" "$cidr" "$protocol" "$port" "$expires" >> "$tmp" + sort -u -o "$tmp" "$tmp" install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" rm -f -- "$tmp" restore_firewall - log "Связь $source → $target включена" + log "Связь $source → $target: $protocol/$port включена" } link_disconnect() { - local source="$1" target="$2" tmp + local source="$1" target="$2" protocol="$3" port="$4" tmp valid_name "$source" && valid_name "$target" || die "Некорректное имя узла" + [[ "$protocol" == tcp || "$protocol" == udp ]] || die "Протокол должен быть tcp или udp" + [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) || die "Некорректный порт" tmp="$(mktemp)" if [[ -f "$LINKS_FILE" ]]; then - awk -v source="$source" -v target="$target" '!($1 == source && $2 == target)' "$LINKS_FILE" > "$tmp" + 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" restore_firewall - log "Связь $source → $target отключена" + log "Связь $source → $target: $protocol/$port отключена" } remove_node() { @@ -477,11 +504,13 @@ list_nodes() { } list_links() { - local source target + local source target cidr protocol port expires + prune_links [[ -f "$LINKS_FILE" ]] || return 0 - while read -r source target; do + while read -r source target cidr protocol port expires; do [[ -n "$source" && -n "$target" ]] || continue - printf 'LINK=%s|%s|enabled\n' "$source" "$target" + printf 'LINK=%s|%s|%s|%s|%s|%s|enabled\n' \ + "$source" "$target" "$cidr" "$protocol" "$port" "$expires" done < "$LINKS_FILE" } @@ -505,8 +534,8 @@ main() { node-remove) [[ $# -eq 2 ]] || die "Использование: $0 node-remove NAME"; remove_node "$2" ;; nodes) list_nodes ;; links) list_links ;; - link-connect) [[ $# -eq 3 ]] || die "Использование: $0 link-connect SOURCE TARGET"; link_connect "$2" "$3" ;; - link-disconnect) [[ $# -eq 3 ]] || die "Использование: $0 link-disconnect SOURCE TARGET"; link_disconnect "$2" "$3" ;; + 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 ;; @@ -530,15 +559,30 @@ Wants=wg-quick@${WG_INTERFACE}.service [Service] Type=oneshot -RemainAfterExit=yes ExecStart=${HUB_HELPER} firewall-restore [Install] WantedBy=multi-user.target EOF chmod 0644 /etc/systemd/system/ochenstarik-smm-firewall.service + cat > /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() { @@ -611,7 +655,7 @@ EOF fi log "Главный Mesh Hub установлен: ${HUB_ENDPOINT_VALUE}:${WG_PORT_VALUE}/udp" log "Создать код узла: sudo ochenstarik-smm node-code ai-agent" - log "Включить связь: sudo ochenstarik-smm link-connect ai-agent home" + log "Включить SSH на 2 часа: sudo ochenstarik-smm link-connect ai-agent home tcp 22 120" } base64url_encode() { diff --git a/tests/test-monitor-manager-links.sh b/tests/test-monitor-manager-links.sh new file mode 100644 index 0000000..0a98698 --- /dev/null +++ b/tests/test-monitor-manager-links.sh @@ -0,0 +1,89 @@ +#!/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 < "$state/links" +[[ "$source" == ai-agent ]] +[[ "$target" == home ]] +[[ "$cidr" == 10.77.0.3/32 ]] +[[ "$protocol" == tcp ]] +[[ "$port" == 2222 ]] +(( expires > $(date +%s) )) +grep -Fq 'ip saddr 10.77.0.2 ip daddr 10.77.0.3/32 tcp dport 2222 accept' "$nft_capture" + +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\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" ]] +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' -- 2.45.2 From 71cc35e9864038c5658edb6ef12194e2d2b954bd Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 19:02:05 +0700 Subject: [PATCH 07/20] Version and audit mesh policy state --- ochenstarik-server-monitor-manager.sh | 89 +++++++++++++++++++++------ tests/test-monitor-manager-links.sh | 10 ++- 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 784ebaa..8fe9188 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -196,6 +196,8 @@ 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" @@ -214,6 +216,26 @@ 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)" @@ -375,11 +397,11 @@ node_enroll() { } prune_links() { - local tmp source target cidr protocol port expires now target_ip + 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 extra; do + 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 @@ -388,8 +410,12 @@ prune_links() { [[ "$protocol" == tcp || "$protocol" == udp ]] || continue [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) || continue [[ "$expires" =~ ^[0-9]+$ ]] || continue - (( expires == 0 || expires > now )) || continue - printf '%s %s %s %s %s %s\n' "$source" "$target" "$cidr" "$protocol" "$port" "$expires" >> "$tmp" + [[ "${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" @@ -398,15 +424,16 @@ prune_links() { } restore_firewall() { - local tmp source target cidr protocol port expires source_ip + 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; do + 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' \ @@ -414,16 +441,22 @@ restore_firewall() { done < "$LINKS_FILE" printf ' iifname "%s" oifname "%s" drop\n' "$WG_INTERFACE" "$WG_INTERFACE" printf ' }\n}\n' - } > "$tmp" - nft --check -f "$tmp" - nft delete table inet "$NFT_TABLE" >/dev/null 2>&1 || true - nft -f "$tmp" + } > "$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 tmp + local target_ip cidr expires version tmp valid_name "$source" && valid_name "$target" || die "Некорректное имя узла" [[ "$source" != "$target" ]] || die "Источник и назначение совпадают" node_exists "$source" || die "Узел $source не найден" @@ -436,24 +469,33 @@ link_connect() { 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\n' "$source" "$target" "$cidr" "$protocol" "$port" "$expires" >> "$tmp" + 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" - restore_firewall + 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" tmp + 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" \ @@ -461,8 +503,13 @@ link_disconnect() { fi install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" rm -f -- "$tmp" - restore_firewall + 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() { @@ -504,13 +551,13 @@ list_nodes() { } list_links() { - local source target cidr protocol port expires + local source target cidr protocol port expires version prune_links [[ -f "$LINKS_FILE" ]] || return 0 - while read -r source target cidr protocol port expires; do + while read -r source target cidr protocol port expires version; do [[ -n "$source" && -n "$target" ]] || continue - printf 'LINK=%s|%s|%s|%s|%s|%s|enabled\n' \ - "$source" "$target" "$cidr" "$protocol" "$port" "$expires" + printf 'LINK=%s|%s|%s|%s|%s|%s|Active|%s\n' \ + "$source" "$target" "$cidr" "$protocol" "$port" "$expires" "$version" done < "$LINKS_FILE" } @@ -637,6 +684,10 @@ install_hub() { 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 diff --git a/tests/test-monitor-manager-links.sh b/tests/test-monitor-manager-links.sh index 0a98698..5a1c411 100644 --- a/tests/test-monitor-manager-links.sh +++ b/tests/test-monitor-manager-links.sh @@ -58,14 +58,17 @@ 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 < "$state/links" +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 @@ -73,13 +76,16 @@ if PATH="$mock_bin:$PATH" NFT_CAPTURE="$nft_capture" \ exit 1 fi -printf 'ai-agent home 10.77.0.3/32 udp 53 1\n' >> "$state/links" +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 -- 2.45.2 From 01968d79a7f6e8213a07aa15fa22712b8f3ae27a Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 19:08:16 +0700 Subject: [PATCH 08/20] Add role-aware update rollback and uninstall --- .github/workflows/bash-syntax.yml | 3 + README.md | 10 ++ docs/readme/README.ru.md | 10 ++ ochenstarik-server-monitor-manager.sh | 139 ++++++++++++++++++++++-- tests/test-monitor-manager-lifecycle.sh | 22 ++++ 5 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 tests/test-monitor-manager-lifecycle.sh diff --git a/.github/workflows/bash-syntax.yml b/.github/workflows/bash-syntax.yml index 306e900..47c0d64 100644 --- a/.github/workflows/bash-syntax.yml +++ b/.github/workflows/bash-syntax.yml @@ -49,3 +49,6 @@ jobs: - 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 diff --git a/README.md b/README.md index 042e5e6..529e38b 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,16 @@ 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 +``` + ## Installation flow 1. Select the wizard dialog language. diff --git a/docs/readme/README.ru.md b/docs/readme/README.ru.md index 549ff84..9a52c6e 100644 --- a/docs/readme/README.ru.md +++ b/docs/readme/README.ru.md @@ -98,6 +98,16 @@ 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 index 8fe9188..49c504f 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -59,14 +59,14 @@ read_public_key() { install_monitor_dependencies() { apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y \ - ca-certificates openssh-server openssh-client coreutils gawk + 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 jq openssl + wireguard-tools nftables iproute2 iputils-ping jq openssl } verify_public_key() { @@ -857,14 +857,84 @@ install_server_part() { show_status } -uninstall_server_part() { - local answer +confirm_action() { + local prompt="$1" answer if [[ -r /dev/tty ]]; then - IFS= read -r -p 'Удалить пользователя мониторинга и forced-command? [y/N]: ' answer < /dev/tty + IFS= read -r -p "$prompt [y/N]: " answer < /dev/tty else - die "Для удаления требуется интерактивный терминал" + die "Для этой операции требуется интерактивный терминал" fi - [[ "$answer" =~ ^[Yy]$ ]] || { log "Отменено"; return 0; } + [[ "$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" "$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; 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" @@ -872,18 +942,63 @@ uninstall_server_part() { 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 + 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" + 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 "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" "$WG_CONFIG" + 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_server_part ;; - hub) install_hub ;; - node) install_node ;; + install|install-monitor) install_server_part ;; + hub|install-hub) install_hub ;; + node|install-node) install_node ;; hub-code) [[ $# -eq 2 ]] || die "Использование: $0 hub-code NAME"; exec "$HUB_HELPER" node-code "$2" ;; status) show_status ;; - uninstall) uninstall_server_part ;; - *) die "Использование: $0 {install|hub|node|hub-code NAME|status|uninstall}" ;; + 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|hub-code NAME|status|update|rollback|uninstall-monitor|uninstall-node|uninstall-hub}" ;; esac } 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' -- 2.45.2 From 4baf8f8b3cfe725898957afddc89af9a26efe99c Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 19:23:47 +0700 Subject: [PATCH 09/20] Add extended server metrics --- .github/workflows/bash-syntax.yml | 3 +++ ochenstarik-server-monitor-manager.sh | 12 ++++++++++++ tests/test-monitor-manager-metrics.sh | 15 +++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 tests/test-monitor-manager-metrics.sh diff --git a/.github/workflows/bash-syntax.yml b/.github/workflows/bash-syntax.yml index 47c0d64..da52d2e 100644 --- a/.github/workflows/bash-syntax.yml +++ b/.github/workflows/bash-syntax.yml @@ -52,3 +52,6 @@ jobs: - 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 diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 49c504f..5fb65a2 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -137,9 +137,21 @@ 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" 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' -- 2.45.2 From b18d834c9fdb482aa8bf8293f8e0e1a57ae750fe Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 20:35:42 +0700 Subject: [PATCH 10/20] Install persistent control Hub and agents --- .github/workflows/bash-syntax.yml | 3 + README.md | 19 ++ ochenstarik-server-monitor-manager.sh | 296 +++++++++++++++++++- tests/test-monitor-manager-control-layer.sh | 26 ++ 4 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 tests/test-monitor-manager-control-layer.sh diff --git a/.github/workflows/bash-syntax.yml b/.github/workflows/bash-syntax.yml index da52d2e..3673bdb 100644 --- a/.github/workflows/bash-syntax.yml +++ b/.github/workflows/bash-syntax.yml @@ -55,3 +55,6 @@ jobs: - 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 529e38b..17bde0c 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,25 @@ 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 +``` + +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. + ## Installation flow 1. Select the wizard dialog language. diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 5fb65a2..642268c 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -19,6 +19,20 @@ 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.1}" +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; } @@ -305,6 +319,125 @@ base64url_decode() { printf '%s' "$data" | base64 -d } +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 +} + +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 зарегистрирован и запущен" +} + payload_value() { local payload="$1" key="$2" printf '%s\n' "$payload" | awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' @@ -679,6 +812,145 @@ read_hub_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 @@ -719,6 +991,7 @@ EOF 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() { @@ -838,6 +1111,7 @@ install_node() { install_monitoring install_node_mesh log "Вторичный сервер установлен" + log "После установки Control Hub добавьте Agent: sudo $0 install-control-agent" } show_status() { @@ -860,6 +1134,8 @@ show_status() { 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() { @@ -890,7 +1166,9 @@ backup_state() { /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; do + /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; } @@ -960,11 +1238,15 @@ uninstall_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" + 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 оставлена" } @@ -976,6 +1258,7 @@ uninstall_hub() { 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 @@ -987,7 +1270,9 @@ uninstall_hub() { /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" "$WG_CONFIG" + "$HUB_HELPER" "$HUB_CLI" "$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 @@ -1002,6 +1287,9 @@ main() { 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" ;; hub-code) [[ $# -eq 2 ]] || die "Использование: $0 hub-code NAME"; exec "$HUB_HELPER" node-code "$2" ;; status) show_status ;; update) update_installed ;; @@ -1010,7 +1298,7 @@ main() { uninstall-node) uninstall_node ;; uninstall-hub) uninstall_hub ;; uninstall) die "Укажите роль: uninstall-monitor, uninstall-node или uninstall-hub" ;; - *) die "Использование: $0 {install-monitor|install-hub|install-node|hub-code NAME|status|update|rollback|uninstall-monitor|uninstall-node|uninstall-hub}" ;; + *) die "Использование: $0 {install-monitor|install-hub|install-node|install-control-hub|install-control-agent|control-code NAME|hub-code NAME|status|update|rollback|uninstall-monitor|uninstall-node|uninstall-hub}" ;; esac } diff --git a/tests/test-monitor-manager-control-layer.sh b/tests/test-monitor-manager-control-layer.sh new file mode 100644 index 0000000..21b58bc --- /dev/null +++ b/tests/test-monitor-manager-control-layer.sh @@ -0,0 +1,26 @@ +#!/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 'control-code)' "$script" +grep -Fq 'install-control-hub)' "$script" +grep -Fq 'install-control-agent)' "$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' -- 2.45.2 From 7b11ce9da2046470a9ba1cf228a4aac1be98cb06 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 20:42:17 +0700 Subject: [PATCH 11/20] Fix control installer heredoc placement --- ochenstarik-server-monitor-manager.sh | 238 +++++++++++++------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 642268c..4068395 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -319,125 +319,6 @@ base64url_decode() { printf '%s' "$data" | base64 -d } -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 -} - -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 зарегистрирован и запущен" -} - payload_value() { local payload="$1" key="$2" printf '%s\n' "$payload" | awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' @@ -1015,6 +896,125 @@ payload_value() { 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 +} + +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" -- 2.45.2 From ae1c0741e55a1c1a92011c1e3a6a30c11a22c534 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 21:59:58 +0700 Subject: [PATCH 12/20] Install SQLite Link control policy --- README.md | 3 + ochenstarik-server-monitor-manager.sh | 78 ++++++++++++++++++--- tests/test-monitor-manager-control-layer.sh | 3 + 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 17bde0c..4e7e750 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ 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: @@ -140,6 +141,8 @@ 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/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 4068395..6a61e0e 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -13,6 +13,7 @@ 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" @@ -31,7 +32,7 @@ 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.1}" +readonly SMM_RELEASE_VERSION="${SMM_RELEASE_VERSION:-v0.1.0-alpha.2}" 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' "$*"; } @@ -659,14 +660,48 @@ EOF } configure_hub_sudo() { - cat > /etc/sudoers.d/ochenstarik-smm-hub </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 @@ -778,6 +813,7 @@ ASPNETCORE_Kestrel__Certificates__Default__Path=${CONTROL_STATE}/server.pfx Control__DatabasePath=${CONTROL_STATE}/control.db Control__CertificateAuthorityPath=${CONTROL_STATE}/control-ca.pfx Control__HeartbeatSeconds=30 +Control__HubHelperPath=${CONTROL_POLICY_HELPER} EOF chmod 0600 "$CONTROL_ENV" cat > "$CONTROL_SERVICE" </dev/null 2>&1 && ufw status | grep -q '^Status: active'; then @@ -917,6 +955,27 @@ create_control_join_code() { 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" @@ -1162,7 +1221,8 @@ backup_state() { 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" "$WG_CONFIG" \ + "$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 \ @@ -1270,7 +1330,8 @@ uninstall_hub() { /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" "$WG_CONFIG" "$CONTROL_SERVICE" "$CONTROL_ENV" "$CONTROL_CA_CERT" + "$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" @@ -1290,6 +1351,7 @@ main() { 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 ;; @@ -1298,7 +1360,7 @@ main() { 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|hub-code NAME|status|update|rollback|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 } diff --git a/tests/test-monitor-manager-control-layer.sh b/tests/test-monitor-manager-control-layer.sh index 21b58bc..717c8f7 100644 --- a/tests/test-monitor-manager-control-layer.sh +++ b/tests/test-monitor-manager-control-layer.sh @@ -9,9 +9,12 @@ 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" -- 2.45.2 From 7191d54a40a3526aaaed71f9bb10a956082b1e62 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 16 Jul 2026 23:35:29 +0700 Subject: [PATCH 13/20] Install server monitor alpha 3 --- ochenstarik-server-monitor-manager.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 6a61e0e..0e1a377 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -32,7 +32,7 @@ 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.2}" +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' "$*"; } -- 2.45.2 From 5d4e8f063d583bb4eeffddec9366fb597bab8a9b Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 09:04:40 +0700 Subject: [PATCH 14/20] Add installer reboot and mesh E2E tests --- .github/workflows/monitor-manager-e2e.yml | 40 +++++ .github/workflows/monitor-manager-release.yml | 43 +++++ README.md | 17 ++ tests/e2e/Dockerfile.systemd | 16 ++ .../test-monitor-manager-systemd-reinstall.sh | 80 ++++++++++ ...test-monitor-manager-wireguard-nftables.sh | 149 ++++++++++++++++++ 6 files changed, 345 insertions(+) create mode 100644 .github/workflows/monitor-manager-e2e.yml create mode 100644 .github/workflows/monitor-manager-release.yml create mode 100644 tests/e2e/Dockerfile.systemd create mode 100644 tests/e2e/test-monitor-manager-systemd-reinstall.sh create mode 100644 tests/e2e/test-monitor-manager-wireguard-nftables.sh diff --git a/.github/workflows/monitor-manager-e2e.yml b/.github/workflows/monitor-manager-e2e.yml new file mode 100644 index 0000000..40e0537 --- /dev/null +++ b/.github/workflows/monitor-manager-e2e.yml @@ -0,0 +1,40 @@ +name: Server Monitor Manager E2E + +on: + push: + branches: ['**'] + paths: + - 'ochenstarik-server-monitor-manager.sh' + - 'tests/e2e/**' + - '.github/workflows/monitor-manager-e2e.yml' + pull_request: + paths: + - 'ochenstarik-server-monitor-manager.sh' + - 'tests/e2e/**' + - '.github/workflows/monitor-manager-e2e.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + wireguard-nftables: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install kernel networking tools + run: sudo apt-get update && sudo apt-get install -y wireguard-tools nftables iproute2 netcat-openbsd + + - name: Test real WireGuard and nftables data path + run: sudo bash tests/e2e/test-monitor-manager-wireguard-nftables.sh + + reinstall-reboot: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Test repeated install and systemd reboot + run: bash tests/e2e/test-monitor-manager-systemd-reinstall.sh diff --git a/.github/workflows/monitor-manager-release.yml b/.github/workflows/monitor-manager-release.yml new file mode 100644 index 0000000..4cf8ef9 --- /dev/null +++ b/.github/workflows/monitor-manager-release.yml @@ -0,0 +1,43 @@ +name: Server Monitor Manager installer release + +on: + workflow_dispatch: + push: + tags: + - 'smm-installer-v*' + +permissions: + contents: write + +jobs: + package: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Validate installer + run: bash -n ochenstarik-server-monitor-manager.sh + + - name: Create installer checksum + shell: bash + run: | + set -Eeuo pipefail + install -d dist + install -m 0755 ochenstarik-server-monitor-manager.sh dist/ochenstarik-server-monitor-manager.sh + cd dist + sha256sum ochenstarik-server-monitor-manager.sh > SHA256SUMS + sha256sum --check SHA256SUMS + + - name: Upload installer artifact + uses: actions/upload-artifact@v6 + with: + name: ochenstarik-server-monitor-manager-installer + path: dist/* + + - name: Attach installer to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + prerelease: ${{ contains(github.ref_name, '-') }} + files: dist/* diff --git a/README.md b/README.md index 8973e63..36ee8ab 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The wizard intentionally requires all module scripts to be present locally. Use | `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 | +| `ochenstarik-server-monitor-manager.sh` | Installs monitoring-only, Hub, or Node roles for Server Monitor Manager | Yes | Every module can be run independently from the full archive or cloned repository: @@ -64,6 +65,22 @@ sudo ./SCRIPT_NAME.sh Replace `SCRIPT_NAME.sh` with the required filename from the table. +The Server Monitor Manager installer supports repeatable role-aware commands: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh install-monitor +sudo ./ochenstarik-server-monitor-manager.sh install-hub +sudo ./ochenstarik-server-monitor-manager.sh install-node +sudo ./ochenstarik-server-monitor-manager.sh status +sudo ./ochenstarik-server-monitor-manager.sh update +``` + +Release artifacts include `SHA256SUMS`. Verify the installer before running it: + +```bash +sha256sum --check SHA256SUMS +``` + ## 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. diff --git a/tests/e2e/Dockerfile.systemd b/tests/e2e/Dockerfile.systemd new file mode 100644 index 0000000..711ccef --- /dev/null +++ b/tests/e2e/Dockerfile.systemd @@ -0,0 +1,16 @@ +FROM debian:bookworm-slim + +ENV container=docker +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends systemd systemd-sysv dbus ca-certificates openssh-client \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && systemctl mask dev-hugepages.mount sys-fs-fuse-connections.mount systemd-remount-fs.service + +COPY . /workspace +RUN chmod 700 /workspace/*.sh + +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/tests/e2e/test-monitor-manager-systemd-reinstall.sh b/tests/e2e/test-monitor-manager-systemd-reinstall.sh new file mode 100644 index 0000000..12e25d1 --- /dev/null +++ b/tests/e2e/test-monitor-manager-systemd-reinstall.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." + +command -v docker >/dev/null || { echo 'docker is required' >&2; exit 1; } +command -v ssh-keygen >/dev/null || { echo 'ssh-keygen is required' >&2; exit 1; } + +run_id="${GITHUB_RUN_ID:-local}-$$" +image="smm-systemd-e2e:${run_id}" +container="smm-systemd-e2e-${run_id}" +temporary="$(mktemp -d)" + +cleanup() { + docker rm -f "$container" >/dev/null 2>&1 || true + docker image rm -f "$image" >/dev/null 2>&1 || true + rm -rf -- "$temporary" +} +trap cleanup EXIT + +ssh-keygen -q -t ed25519 -N '' -C 'smm-e2e' -f "$temporary/monitor-key" +public_key="$(<"$temporary/monitor-key.pub")" + +docker build --pull -f tests/e2e/Dockerfile.systemd -t "$image" . +docker run -d \ + --name "$container" \ + --privileged \ + --cgroupns=host \ + --tmpfs /run \ + --tmpfs /run/lock \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + "$image" >/dev/null + +for _ in $(seq 1 30); do + docker exec "$container" systemctl show --property=Version >/dev/null 2>&1 && break + sleep 1 +done +docker exec "$container" systemctl show --property=Version >/dev/null + +install_hub() { + docker exec \ + -e "SERVER_MONITOR_PUBLIC_KEY=$public_key" \ + -e SMM_HUB_ENDPOINT=203.0.113.10 \ + -e SMM_WG_PORT=51820 \ + "$container" \ + /workspace/ochenstarik-server-monitor-manager.sh install-hub +} + +install_hub +first_private_key_hash="$(docker exec "$container" sha256sum /etc/ochenstarik-server-monitor-manager/hub.key | awk '{ print $1 }')" + +# A repeated install must repair units/configuration without rotating the Hub identity. +install_hub +second_private_key_hash="$(docker exec "$container" sha256sum /etc/ochenstarik-server-monitor-manager/hub.key | awk '{ print $1 }')" +[[ "$first_private_key_hash" == "$second_private_key_hash" ]] +[[ "$(docker exec "$container" grep -c 'ochenstarik-server-monitor' /var/lib/ochenstarik-server-monitor-manager/.ssh/authorized_keys)" -eq 1 ]] + +# Restarting the systemd container is the CI reboot boundary: the writable rootfs is retained, +# PID 1 starts again, and only enabled units can reconstruct runtime state. +docker restart --timeout 20 "$container" >/dev/null +for _ in $(seq 1 45); do + if docker exec "$container" systemctl is-active --quiet wg-quick@smm0.service \ + && docker exec "$container" systemctl is-active --quiet ochenstarik-smm-firewall.timer \ + && docker exec "$container" systemctl is-active --quiet ssh.service; then + break + fi + sleep 1 +done + +docker exec "$container" systemctl is-enabled --quiet wg-quick@smm0.service +docker exec "$container" systemctl is-enabled --quiet ochenstarik-smm-firewall.service +docker exec "$container" systemctl is-enabled --quiet ochenstarik-smm-firewall.timer +docker exec "$container" systemctl is-active --quiet wg-quick@smm0.service +docker exec "$container" systemctl is-active --quiet ochenstarik-smm-firewall.timer +docker exec "$container" systemctl start ochenstarik-smm-firewall.service +docker exec "$container" ip address show dev smm0 | grep -Fq '10.77.0.1/24' +docker exec "$container" nft list table inet ochenstarik_smm | grep -Fq 'iifname "smm0" oifname "smm0" drop' +docker exec "$container" test "$(sha256sum /etc/ochenstarik-server-monitor-manager/hub.key | awk '{ print $1 }')" = "$first_private_key_hash" + +printf 'Server Monitor Manager repeated-install and reboot checks passed.\n' diff --git a/tests/e2e/test-monitor-manager-wireguard-nftables.sh b/tests/e2e/test-monitor-manager-wireguard-nftables.sh new file mode 100644 index 0000000..d21d7f4 --- /dev/null +++ b/tests/e2e/test-monitor-manager-wireguard-nftables.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." + +for command in ip wg nft nc timeout awk sed; do + command -v "$command" >/dev/null || { echo "$command is required" >&2; exit 1; } +done +[[ "$EUID" -eq 0 ]] || { echo 'run as root' >&2; exit 1; } + +temporary="$(mktemp -d)" +suffix="${GITHUB_RUN_ID:-$$}" +hub="smm-hub-${suffix}" +source="smm-source-${suffix}" +target="smm-target-${suffix}" +listener_pid='' + +cleanup() { + [[ -z "$listener_pid" ]] || kill "$listener_pid" >/dev/null 2>&1 || true + ip netns delete "$source" >/dev/null 2>&1 || true + ip netns delete "$target" >/dev/null 2>&1 || true + ip netns delete "$hub" >/dev/null 2>&1 || true + rm -rf -- "$temporary" +} +trap cleanup EXIT + +for namespace in "$hub" "$source" "$target"; do + ip netns add "$namespace" + ip -n "$namespace" link set lo up +done + +ip link add smm-hub-source type veth peer name smm-source-wan +ip link set smm-hub-source netns "$hub" +ip link set smm-source-wan netns "$source" +ip -n "$hub" address add 192.0.2.1/30 dev smm-hub-source +ip -n "$source" address add 192.0.2.2/30 dev smm-source-wan +ip -n "$hub" link set smm-hub-source up +ip -n "$source" link set smm-source-wan up + +ip link add smm-hub-target type veth peer name smm-target-wan +ip link set smm-hub-target netns "$hub" +ip link set smm-target-wan netns "$target" +ip -n "$hub" address add 192.0.2.5/30 dev smm-hub-target +ip -n "$target" address add 192.0.2.6/30 dev smm-target-wan +ip -n "$hub" link set smm-hub-target up +ip -n "$target" link set smm-target-wan up + +umask 077 +wg genkey > "$temporary/hub.key" +wg genkey > "$temporary/source.key" +wg genkey > "$temporary/target.key" +wg pubkey < "$temporary/hub.key" > "$temporary/hub.pub" +wg pubkey < "$temporary/source.key" > "$temporary/source.pub" +wg pubkey < "$temporary/target.key" > "$temporary/target.pub" + +for namespace in "$hub" "$source" "$target"; do + ip -n "$namespace" link add smm0 type wireguard +done +ip netns exec "$hub" wg set smm0 \ + private-key "$temporary/hub.key" listen-port 51820 \ + peer "$(<"$temporary/source.pub")" allowed-ips 10.77.0.2/32 \ + peer "$(<"$temporary/target.pub")" allowed-ips 10.77.0.3/32 +ip netns exec "$source" wg set smm0 \ + private-key "$temporary/source.key" \ + peer "$(<"$temporary/hub.pub")" endpoint 192.0.2.1:51820 allowed-ips 10.77.0.0/24 persistent-keepalive 1 +ip netns exec "$target" wg set smm0 \ + private-key "$temporary/target.key" \ + peer "$(<"$temporary/hub.pub")" endpoint 192.0.2.5:51820 allowed-ips 10.77.0.0/24 persistent-keepalive 1 + +ip -n "$hub" address add 10.77.0.1/24 dev smm0 +ip -n "$source" address add 10.77.0.2/32 dev smm0 +ip -n "$target" address add 10.77.0.3/32 dev smm0 +ip -n "$source" route add 10.77.0.0/24 dev smm0 +ip -n "$target" route add 10.77.0.0/24 dev smm0 +for namespace in "$hub" "$source" "$target"; do + ip -n "$namespace" link set smm0 up +done +ip netns exec "$hub" sysctl -q -w net.ipv4.ip_forward=1 + +helper="$temporary/ochenstarik-smm-hub" +awk ' + capture && /^EOF$/ { exit } + /cat > .*HUB_HELPER.*< "$helper" +chmod 700 "$helper" +state="$temporary/state" +mkdir -p "$state/nodes" "$state/tokens" +sed -i "s|^readonly STATE_DIR=.*|readonly STATE_DIR=\"$state\"|" "$helper" +sed -i "s|^readonly WG_CONFIG=.*|readonly WG_CONFIG=\"$temporary/smm0.conf\"|" "$helper" +cat > "$state/hub.conf" <<'EOF' +HUB_ENDPOINT=203.0.113.10 +WG_PORT=51820 +HUB_ADDRESS=10.77.0.1 +MESH_CIDR=10.77.0.0/24 +EOF +cp "$temporary/hub.key" "$state/hub.key" +cat > "$state/nodes/source.node" < "$state/nodes/target.node" < "$state/policy.version" + +start_listener() { + local namespace="$1" + ip netns exec "$namespace" sh -c 'printf allowed | timeout 10 nc -l 2222' & + listener_pid=$! + sleep 0.2 +} + +ip netns exec "$hub" "$helper" link-connect source target tcp 2222 120 +start_listener "$target" +response="$(timeout 8 ip netns exec "$source" nc -w 5 10.77.0.3 2222)" +wait "$listener_pid" +listener_pid='' +[[ "$response" == allowed ]] + +# The policy is directional: the reverse connection must remain blocked. +start_listener "$source" +if timeout 3 ip netns exec "$target" nc -w 2 10.77.0.2 2222 >/dev/null 2>&1; then + echo 'Reverse traffic bypassed the directional Link.' >&2 + exit 1 +fi +kill "$listener_pid" >/dev/null 2>&1 || true +wait "$listener_pid" 2>/dev/null || true +listener_pid='' + +ip netns exec "$hub" "$helper" link-disconnect source target tcp 2222 +start_listener "$target" +if timeout 3 ip netns exec "$source" nc -w 2 10.77.0.3 2222 >/dev/null 2>&1; then + echo 'Traffic remained available after the Link kill switch.' >&2 + exit 1 +fi +kill "$listener_pid" >/dev/null 2>&1 || true +wait "$listener_pid" 2>/dev/null || true +listener_pid='' + +ip netns exec "$hub" wg show smm0 latest-handshakes | awk '$2 > 0 { found=1 } END { exit !found }' +grep -Fq '"state":"Active"' "$state/audit.jsonl" +grep -Fq '"state":"Disabled"' "$state/audit.jsonl" + +printf 'Real WireGuard and nftables directional Link checks passed.\n' -- 2.45.2 From b0a0726e5cebd2e9f692ab4c9bf744026c47422e Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 09:06:58 +0700 Subject: [PATCH 15/20] Ensure monitoring SSH authentication is enabled --- ochenstarik-server-monitor-manager.sh | 14 ++++++++++++-- tests/test-monitor-manager-lifecycle.sh | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index 0e1a377..d76a225 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -6,6 +6,7 @@ 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 MONITOR_SSH_CONFIG="/etc/ssh/sshd_config.d/90-ochenstarik-server-monitor.conf" readonly MESH_DIR="/etc/${APP_NAME}" readonly NODES_DIR="${MESH_DIR}/nodes" readonly TOKENS_DIR="${MESH_DIR}/tokens" @@ -192,6 +193,14 @@ create_monitor_user() { printf 'restrict,command="%s" %s\n' "$MONITOR_COMMAND" "$PUBLIC_KEY" > "$AUTHORIZED_KEYS" chown "$MONITOR_USER:$MONITOR_USER" "$AUTHORIZED_KEYS" chmod 0600 "$AUTHORIZED_KEYS" + + install -d -m 0755 -o root -g root /etc/ssh/sshd_config.d + cat > "$MONITOR_SSH_CONFIG" <<'EOF' +# Required by the restricted Server Monitor Manager identity. +PubkeyAuthentication yes +EOF + chown root:root "$MONITOR_SSH_CONFIG" + chmod 0644 "$MONITOR_SSH_CONFIG" } verify_sshd() { @@ -1221,7 +1230,7 @@ backup_state() { 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" \ + "$MESH_DIR" "$MONITOR_HOME" "$MONITOR_COMMAND" "$MONITOR_SSH_CONFIG" "$HUB_HELPER" "$HUB_CLI" \ "$CONTROL_POLICY_HELPER" "$WG_CONFIG" \ /etc/systemd/system/ochenstarik-smm-firewall.service \ /etc/systemd/system/ochenstarik-smm-firewall.timer \ @@ -1285,10 +1294,11 @@ uninstall_monitor() { || die "Сначала удалите роль командой uninstall-hub или uninstall-node" confirm_action "Удалить пользователя мониторинга и forced-command" || { log "Отменено"; return 0; } backup_state - rm -f -- "$MONITOR_COMMAND" + rm -f -- "$MONITOR_COMMAND" "$MONITOR_SSH_CONFIG" if getent passwd "$MONITOR_USER" >/dev/null; then userdel --remove "$MONITOR_USER" 2>/dev/null || userdel "$MONITOR_USER" fi + systemctl reload ssh log "Серверная часть мониторинга удалена" } diff --git a/tests/test-monitor-manager-lifecycle.sh b/tests/test-monitor-manager-lifecycle.sh index c663322..3f0b866 100644 --- a/tests/test-monitor-manager-lifecycle.sh +++ b/tests/test-monitor-manager-lifecycle.sh @@ -11,6 +11,8 @@ grep -Fq 'backup_state()' "$script" grep -Fq 'rollback_state()' "$script" grep -Fq 'update_installed()' "$script" grep -Fq 'uninstall_monitor()' "$script" +grep -Fq '90-ochenstarik-server-monitor.conf' "$script" +grep -Fq 'PubkeyAuthentication yes' "$script" grep -Fq 'uninstall_node()' "$script" grep -Fq 'uninstall_hub()' "$script" grep -Fq '/var/backups/${APP_NAME}' "$script" -- 2.45.2 From 4e8abf82c311bdf466b2fffc6d652f93744c640a Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 09:22:14 +0700 Subject: [PATCH 16/20] Fix installer SSH context and mesh setup order --- ochenstarik-server-monitor-manager.sh | 9 ++++++--- tests/e2e/test-monitor-manager-wireguard-nftables.sh | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index d76a225..b0626df 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -196,8 +196,10 @@ create_monitor_user() { install -d -m 0755 -o root -g root /etc/ssh/sshd_config.d cat > "$MONITOR_SSH_CONFIG" <<'EOF' -# Required by the restricted Server Monitor Manager identity. -PubkeyAuthentication yes +# Required by the restricted Server Monitor Manager identity only. +Match User ochenstarik-monitor + PubkeyAuthentication yes +Match all EOF chown root:root "$MONITOR_SSH_CONFIG" chmod 0644 "$MONITOR_SSH_CONFIG" @@ -205,7 +207,8 @@ EOF verify_sshd() { sshd -t - sshd -T | grep -qi '^pubkeyauthentication yes$' \ + sshd -T -C "user=${MONITOR_USER},host=localhost,addr=127.0.0.1" \ + | grep -qi '^pubkeyauthentication yes$' \ || die "В sshd отключена аутентификация по публичному ключу" } diff --git a/tests/e2e/test-monitor-manager-wireguard-nftables.sh b/tests/e2e/test-monitor-manager-wireguard-nftables.sh index d21d7f4..d0f38d8 100644 --- a/tests/e2e/test-monitor-manager-wireguard-nftables.sh +++ b/tests/e2e/test-monitor-manager-wireguard-nftables.sh @@ -70,11 +70,11 @@ ip netns exec "$target" wg set smm0 \ ip -n "$hub" address add 10.77.0.1/24 dev smm0 ip -n "$source" address add 10.77.0.2/32 dev smm0 ip -n "$target" address add 10.77.0.3/32 dev smm0 -ip -n "$source" route add 10.77.0.0/24 dev smm0 -ip -n "$target" route add 10.77.0.0/24 dev smm0 for namespace in "$hub" "$source" "$target"; do ip -n "$namespace" link set smm0 up done +ip -n "$source" route add 10.77.0.0/24 dev smm0 +ip -n "$target" route add 10.77.0.0/24 dev smm0 ip netns exec "$hub" sysctl -q -w net.ipv4.ip_forward=1 helper="$temporary/ochenstarik-smm-hub" -- 2.45.2 From 0009e9f5e9016bebb3d60f6ea5ec8cc6a4d56194 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 09:27:08 +0700 Subject: [PATCH 17/20] Avoid false SSH validation failures under pipefail --- ochenstarik-server-monitor-manager.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index b0626df..c8d5227 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -208,7 +208,7 @@ EOF verify_sshd() { sshd -t sshd -T -C "user=${MONITOR_USER},host=localhost,addr=127.0.0.1" \ - | grep -qi '^pubkeyauthentication yes$' \ + | awk '$1 == "pubkeyauthentication" && $2 == "yes" { enabled=1 } END { exit !enabled }' \ || die "В sshd отключена аутентификация по публичному ключу" } -- 2.45.2 From e51c2b9bc188d35e6680c90ea7b40a3b211fdfb1 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 09:31:35 +0700 Subject: [PATCH 18/20] Report reboot service failures in CI --- tests/e2e/test-monitor-manager-systemd-reinstall.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/e2e/test-monitor-manager-systemd-reinstall.sh b/tests/e2e/test-monitor-manager-systemd-reinstall.sh index 12e25d1..b3feae8 100644 --- a/tests/e2e/test-monitor-manager-systemd-reinstall.sh +++ b/tests/e2e/test-monitor-manager-systemd-reinstall.sh @@ -67,6 +67,17 @@ for _ in $(seq 1 45); do sleep 1 done +for unit in wg-quick@smm0.service ochenstarik-smm-firewall.timer ssh.service; do + if ! docker exec "$container" systemctl is-active --quiet "$unit"; then + state="$(docker exec "$container" systemctl show "$unit" \ + --property=ActiveState,SubState,Result --value | tr '\n' ' ')" + journal="$(docker exec "$container" journalctl -u "$unit" -n 12 --no-pager 2>&1 \ + | tr '\n' ' ' | sed 's/%/%25/g; s/\r/%0D/g')" + printf '::error title=Reboot unit failed::%s: %s; %s\n' "$unit" "$state" "$journal" + exit 1 + fi +done + docker exec "$container" systemctl is-enabled --quiet wg-quick@smm0.service docker exec "$container" systemctl is-enabled --quiet ochenstarik-smm-firewall.service docker exec "$container" systemctl is-enabled --quiet ochenstarik-smm-firewall.timer -- 2.45.2 From 7b9cc79c53d670c14e80f0ec08325418a8448d7b Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 09:35:21 +0700 Subject: [PATCH 19/20] Verify reboot key inside systemd container --- tests/e2e/test-monitor-manager-systemd-reinstall.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test-monitor-manager-systemd-reinstall.sh b/tests/e2e/test-monitor-manager-systemd-reinstall.sh index b3feae8..7e5706c 100644 --- a/tests/e2e/test-monitor-manager-systemd-reinstall.sh +++ b/tests/e2e/test-monitor-manager-systemd-reinstall.sh @@ -86,6 +86,8 @@ docker exec "$container" systemctl is-active --quiet ochenstarik-smm-firewall.ti docker exec "$container" systemctl start ochenstarik-smm-firewall.service docker exec "$container" ip address show dev smm0 | grep -Fq '10.77.0.1/24' docker exec "$container" nft list table inet ochenstarik_smm | grep -Fq 'iifname "smm0" oifname "smm0" drop' -docker exec "$container" test "$(sha256sum /etc/ochenstarik-server-monitor-manager/hub.key | awk '{ print $1 }')" = "$first_private_key_hash" +reboot_private_key_hash="$(docker exec "$container" \ + sha256sum /etc/ochenstarik-server-monitor-manager/hub.key | awk '{ print $1 }')" +[[ "$reboot_private_key_hash" == "$first_private_key_hash" ]] printf 'Server Monitor Manager repeated-install and reboot checks passed.\n' -- 2.45.2 From b4ec88923bcbca1feaee9114fca11a5117214670 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 09:35:59 +0700 Subject: [PATCH 20/20] Target Server Monitor Manager alpha 5 artifacts --- ochenstarik-server-monitor-manager.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ochenstarik-server-monitor-manager.sh b/ochenstarik-server-monitor-manager.sh index c8d5227..0c60f22 100644 --- a/ochenstarik-server-monitor-manager.sh +++ b/ochenstarik-server-monitor-manager.sh @@ -33,7 +33,7 @@ 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_VERSION="${SMM_RELEASE_VERSION:-v0.1.0-alpha.5}" 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' "$*"; } -- 2.45.2