#!/usr/bin/env bash set -Eeuo pipefail readonly APP_NAME="ochenstarik-server-monitor-manager" readonly MONITOR_USER="ochenstarik-monitor" readonly MONITOR_HOME="/var/lib/${APP_NAME}" readonly MONITOR_COMMAND="/usr/local/libexec/ochenstarik-server-monitor" readonly AUTHORIZED_KEYS="${MONITOR_HOME}/.ssh/authorized_keys" readonly MESH_DIR="/etc/${APP_NAME}" readonly NODES_DIR="${MESH_DIR}/nodes" readonly TOKENS_DIR="${MESH_DIR}/tokens" readonly HUB_CONFIG="${MESH_DIR}/hub.conf" readonly HUB_PRIVATE_KEY="${MESH_DIR}/hub.key" readonly LINKS_FILE="${MESH_DIR}/links" readonly HUB_HELPER="/usr/local/libexec/ochenstarik-smm-hub" readonly 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; } die() { printf '[x] %s\n' "$*" >&2; exit 1; } require_root() { [[ "$EUID" -eq 0 ]] || die "Запустите скрипт через sudo" } detect_system() { [[ -r /etc/os-release ]] || die "Не найден /etc/os-release" # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}" in ubuntu|debian) ;; *) die "Поддерживаются Ubuntu и Debian; обнаружено: ${ID:-unknown}" ;; esac command -v systemctl >/dev/null 2>&1 || die "Требуется systemd" } read_public_key() { if [[ -n "${SERVER_MONITOR_PUBLIC_KEY:-}" ]]; then PUBLIC_KEY="$SERVER_MONITOR_PUBLIC_KEY" log "Публичный ключ получен из SERVER_MONITOR_PUBLIC_KEY" else printf '\nВ Windows-приложении нажмите «SSH-ключ» → «Копировать».\n' if [[ -r /dev/tty ]]; then IFS= read -r -p 'Вставьте публичный ключ: ' PUBLIC_KEY < /dev/tty else die "Нет интерактивного терминала. Передайте ключ через SERVER_MONITOR_PUBLIC_KEY" fi fi PUBLIC_KEY="${PUBLIC_KEY//$'\r'/}" [[ "$PUBLIC_KEY" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/]+={0,3}([[:space:]].*)?$ ]] \ || die "Ожидается публичный ключ формата ssh-ed25519 AAAA..." } install_monitor_dependencies() { apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y \ ca-certificates openssh-server openssh-client coreutils gawk 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)" trap 'rm -f -- "${key_file:-}"' RETURN printf '%s\n' "$PUBLIC_KEY" > "$key_file" ssh-keygen -l -f "$key_file" >/dev/null \ || die "ssh-keygen отклонил публичный ключ" trap - RETURN rm -f -- "$key_file" } create_monitor_command() { install -d -m 0755 -o root -g root "$(dirname "$MONITOR_COMMAND")" [[ ! -L "$MONITOR_COMMAND" ]] \ || die "Отказ от записи через символическую ссылку: $MONITOR_COMMAND" cat > "$MONITOR_COMMAND" <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail export LC_ALL=C readonly HUB_HELPER="/usr/local/libexec/ochenstarik-smm-hub" original_command="${SSH_ORIGINAL_COMMAND:-metrics}" case "$original_command" in ""|metrics) ;; "mesh nodes") [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } exec sudo -n "$HUB_HELPER" nodes ;; "mesh links") [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } exec sudo -n "$HUB_HELPER" links ;; "mesh status") [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } exec sudo -n "$HUB_HELPER" status ;; "mesh connect "*) [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } read -r prefix action source target protocol port ttl extra <<< "$original_command" [[ "$prefix" == mesh && "$action" == connect && -n "$ttl" && -z "${extra:-}" ]] \ || { echo "Некорректная команда" >&2; exit 2; } exec sudo -n "$HUB_HELPER" link-connect "$source" "$target" "$protocol" "$port" "$ttl" ;; "mesh disconnect "*) [[ -x "$HUB_HELPER" ]] || { echo "Mesh Hub не установлен" >&2; exit 1; } read -r prefix action source target protocol port extra <<< "$original_command" [[ "$prefix" == mesh && "$action" == disconnect && -n "$port" && -z "${extra:-}" ]] \ || { echo "Некорректная команда" >&2; exit 2; } exec sudo -n "$HUB_HELPER" link-disconnect "$source" "$target" "$protocol" "$port" ;; *) echo "Разрешены только metrics и команды mesh" >&2 exit 2 ;; esac read_mem_value() { awk -v key="$1" '$1 == key ":" { print $2; exit }' /proc/meminfo } printf 'PROTOCOL=1\n' printf 'HOSTNAME=%s\n' "$(hostname)" printf 'UPTIME_SECONDS=%s\n' "$(cut -d. -f1 /proc/uptime)" printf 'LOAD1=%s\n' "$(cut -d' ' -f1 /proc/loadavg)" printf 'CPU_COUNT=%s\n' "$(getconf _NPROCESSORS_ONLN)" printf 'MEM_TOTAL_KB=%s\n' "$(read_mem_value MemTotal)" printf 'MEM_AVAILABLE_KB=%s\n' "$(read_mem_value MemAvailable)" 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 отключена аутентификация по публичному ключу" } install_monitoring() { read_public_key install_monitor_dependencies verify_public_key create_monitor_command create_monitor_user verify_sshd systemctl reload ssh } create_hub_helper() { install -d -m 0755 -o root -g root "$(dirname "$HUB_HELPER")" cat > "$HUB_HELPER" <<'EOF' #!/usr/bin/env bash set -Eeuo pipefail export LC_ALL=C readonly STATE_DIR="/etc/ochenstarik-server-monitor-manager" readonly NODES_DIR="${STATE_DIR}/nodes" readonly TOKENS_DIR="${STATE_DIR}/tokens" readonly HUB_CONFIG="${STATE_DIR}/hub.conf" readonly HUB_PRIVATE_KEY="${STATE_DIR}/hub.key" readonly LINKS_FILE="${STATE_DIR}/links" readonly 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 "$TOKENS_DIR"/*.token*; do [[ -e "$file" ]] || continue if [[ "$(config_value ADDRESS "$file")" == "$address" ]]; then used=true break fi done [[ "$used" == true ]] || { printf '%s' "$address"; return 0; } done die "В подсети 10.77.0.0/24 закончились адреса" } base64url_encode() { base64 -w0 | tr '+/' '-_' | tr -d '=' } base64url_decode() { local data="$1" remainder data="${data//-/+}" data="${data//_/\/}" remainder=$(( ${#data} % 4 )) if (( remainder == 2 )); then data+='==' elif (( remainder == 3 )); then data+='=' elif (( remainder == 1 )); then return 1 fi printf '%s' "$data" | base64 -d } payload_value() { local payload="$1" key="$2" printf '%s\n' "$payload" | awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' } token_cleanup() { local file expires now now="$(date +%s)" for file in "$TOKENS_DIR"/*.token; do [[ -e "$file" ]] || continue expires="$(config_value EXPIRES "$file")" [[ "$expires" =~ ^[0-9]+$ ]] || { rm -f -- "$file"; continue; } (( expires > now )) || rm -f -- "$file" done } node_code() { local name="$1" address endpoint port payload code token token_hash expires file valid_name "$name" || die "Имя: строчные латинские буквы, цифры и дефис, максимум 32 символа" node_exists "$name" && die "Узел $name уже существует" token_cleanup for file in "$TOKENS_DIR"/*.token; do [[ -e "$file" ]] || continue [[ "$(config_value NAME "$file")" != "$name" ]] \ || die "Для узла $name уже существует действующий enrollment-код" done address="$(next_address)" endpoint="$(config_value HUB_ENDPOINT "$HUB_CONFIG")" port="$(config_value WG_PORT "$HUB_CONFIG")" token="$(openssl rand -hex 32)" token_hash="$(printf '%s' "$token" | sha256sum | awk '{ print $1 }')" expires=$(( $(date +%s) + 600 )) file="${TOKENS_DIR}/${token_hash}.token" umask 077 { printf 'NAME=%s\n' "$name" printf 'ADDRESS=%s\n' "$address" printf 'EXPIRES=%s\n' "$expires" } > "$file" payload="$(printf 'VERSION=2\nNAME=%s\nADDRESS=%s/32\nTOKEN=%s\nEXPIRES=%s\nHUB_PUBLIC_KEY=%s\nENDPOINT=%s:%s\nALLOWED_IPS=10.77.0.0/24\n' \ "$name" "$address" "$token" "$expires" "$(wg pubkey < "$HUB_PRIVATE_KEY")" "$endpoint" "$port")" code="SMM2-$(printf '%s' "$payload" | base64url_encode)" unset token payload printf '\nОдноразовый enrollment-код для узла %s (действует 10 минут):\n%s\n\n' "$name" "$code" printf 'Приватный WireGuard-ключ будет создан только на Node. Не сохраняйте код в чатах или логах.\n' } node_enroll() { local request="${SMM_ENROLL_REQUEST:-}" payload version token token_hash token_file consuming_file local name address public_key stored_name stored_address expires now node_path ack local hub_public_key endpoint allowed_ips if [[ -z "$request" && -r /dev/tty ]]; then IFS= read -r -s -p 'Вставьте request-код SMMREQ1: ' request < /dev/tty printf '\n' fi [[ "$request" == SMMREQ1-* ]] || die "Ожидается request-код SMMREQ1-..." payload="$(base64url_decode "${request#SMMREQ1-}")" || die "Не удалось декодировать request-код" version="$(payload_value "$payload" VERSION)" token="$(payload_value "$payload" TOKEN)" name="$(payload_value "$payload" NAME)" address="$(payload_value "$payload" ADDRESS)" public_key="$(payload_value "$payload" PUBLIC_KEY)" [[ "$version" == 1 ]] || die "Неподдерживаемая версия request-кода" [[ "$token" =~ ^[a-f0-9]{64}$ ]] || die "Некорректный enrollment token" valid_name "$name" || die "Некорректное имя Node" [[ "$address" =~ ^10\.77\.0\.([2-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])/32$ ]] \ || die "Некорректный адрес Node" [[ "$(printf '%s' "$public_key" | base64 -d 2>/dev/null | wc -c)" -eq 32 ]] \ || die "Некорректный публичный WireGuard-ключ Node" token_hash="$(printf '%s' "$token" | sha256sum | awk '{ print $1 }')" token_file="${TOKENS_DIR}/${token_hash}.token" [[ -f "$token_file" ]] || die "Enrollment token не найден, уже использован или истёк" stored_name="$(config_value NAME "$token_file")" stored_address="$(config_value ADDRESS "$token_file")" expires="$(config_value EXPIRES "$token_file")" now="$(date +%s)" [[ "$stored_name" == "$name" && "${address%/32}" == "$stored_address" ]] \ || die "Параметры Node не совпадают с enrollment token" [[ "$expires" =~ ^[0-9]+$ ]] && (( expires > now )) \ || { rm -f -- "$token_file"; die "Enrollment token истёк"; } node_exists "$name" && die "Узел $name уже зарегистрирован" consuming_file="${token_file}.consuming" mv -- "$token_file" "$consuming_file" \ || die "Enrollment token уже обрабатывается" node_path="$(node_file "$name")" umask 077 { printf 'NAME=%s\n' "$name" printf 'ADDRESS=%s\n' "${address%/32}" printf 'PUBLIC_KEY=%s\n' "$public_key" } > "$node_path" render_wireguard_config rm -f -- "$consuming_file" hub_public_key="$(wg pubkey < "$HUB_PRIVATE_KEY")" endpoint="$(config_value HUB_ENDPOINT "$HUB_CONFIG"):$(config_value WG_PORT "$HUB_CONFIG")" allowed_ips="10.77.0.0/24" ack="$(printf '%s|%s|%s|%s|%s|%s|%s' \ "$token" "$public_key" "$name" "$address" "$hub_public_key" "$endpoint" "$allowed_ips" \ | sha256sum | awk '{ print $1 }')" unset token payload printf '\nNode зарегистрирован. Верните этот код в установщик Node:\nSMMACK1-%s\n\n' "$ack" } prune_links() { local tmp source target cidr protocol port expires 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)" { 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 [[ -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" 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 не найден" [[ "$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)" 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: $protocol/$port включена" } link_disconnect() { 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" -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: $protocol/$port отключена" } remove_node() { local name="$1" public_key tmp valid_name "$name" || die "Некорректное имя узла" node_exists "$name" || die "Узел $name не найден" public_key="$(node_value "$name" PUBLIC_KEY)" wg set "$WG_INTERFACE" peer "$public_key" remove 2>/dev/null || true rm -f -- "$(node_file "$name")" tmp="$(mktemp)" if [[ -f "$LINKS_FILE" ]]; then awk -v name="$name" '$1 != name && $2 != name' "$LINKS_FILE" > "$tmp" fi install -m 0600 -o root -g root "$tmp" "$LINKS_FILE" rm -f -- "$tmp" render_wireguard_config restore_firewall log "Узел $name удалён" } list_nodes() { local file name address public_key handshake now state age now="$(date +%s)" for file in "$NODES_DIR"/*.node; do [[ -e "$file" ]] || continue name="$(config_value NAME "$file")" address="$(config_value ADDRESS "$file")" public_key="$(config_value PUBLIC_KEY "$file")" handshake="$(wg show "$WG_INTERFACE" latest-handshakes 2>/dev/null | awk -v key="$public_key" '$1 == key { print $2; exit }')" handshake="${handshake:-0}" state=offline age=-1 if [[ "$handshake" =~ ^[0-9]+$ && "$handshake" -gt 0 ]]; then age=$((now - handshake)) (( age <= 180 )) && state=online fi printf 'NODE=%s|%s|%s|%s\n' "$name" "$address" "$state" "$age" done } list_links() { local source target cidr protocol port expires prune_links [[ -f "$LINKS_FILE" ]] || return 0 while read -r source target cidr protocol port expires; do [[ -n "$source" && -n "$target" ]] || continue printf 'LINK=%s|%s|%s|%s|%s|%s|enabled\n' \ "$source" "$target" "$cidr" "$protocol" "$port" "$expires" done < "$LINKS_FILE" } status() { printf 'ROLE=hub\nINTERFACE=%s\nADDRESS=%s\nENDPOINT=%s:%s\n' \ "$WG_INTERFACE" \ "$(config_value HUB_ADDRESS "$HUB_CONFIG")" \ "$(config_value HUB_ENDPOINT "$HUB_CONFIG")" \ "$(config_value WG_PORT "$HUB_CONFIG")" list_nodes list_links } main() { local action="${1:-status}" require_root [[ -r "$HUB_CONFIG" ]] || die "Mesh Hub не установлен" case "$action" in node-code) [[ $# -eq 2 ]] || die "Использование: $0 node-code NAME"; node_code "$2" ;; node-enroll) [[ $# -eq 1 ]] || die "Использование: $0 node-enroll"; node_enroll ;; node-remove) [[ $# -eq 2 ]] || die "Использование: $0 node-remove NAME"; remove_node "$2" ;; nodes) list_nodes ;; links) list_links ;; link-connect) [[ $# -eq 6 ]] || die "Использование: $0 link-connect SOURCE TARGET tcp|udp PORT TTL_MINUTES"; link_connect "$2" "$3" "$4" "$5" "$6" ;; link-disconnect) [[ $# -eq 5 ]] || die "Использование: $0 link-disconnect SOURCE TARGET tcp|udp PORT"; link_disconnect "$2" "$3" "$4" "$5" ;; render) render_wireguard_config ;; firewall-restore) restore_firewall ;; status) status ;; *) die "Команды: node-code, node-enroll, node-remove, nodes, links, link-connect, link-disconnect, status" ;; esac } main "$@" EOF chown root:root "$HUB_HELPER" chmod 0750 "$HUB_HELPER" ln -sfn "$HUB_HELPER" "$HUB_CLI" } configure_hub_firewall_service() { cat > /etc/systemd/system/ochenstarik-smm-firewall.service < /etc/systemd/system/ochenstarik-smm-firewall.timer <<'EOF' [Unit] Description=Expire Server Monitor mesh policies [Timer] OnBootSec=1min OnUnitActiveSec=1min AccuracySec=10s Persistent=true Unit=ochenstarik-smm-firewall.service [Install] WantedBy=timers.target EOF chmod 0644 /etc/systemd/system/ochenstarik-smm-firewall.timer systemctl daemon-reload systemctl enable --now ochenstarik-smm-firewall.service systemctl enable --now ochenstarik-smm-firewall.timer } configure_hub_sudo() { cat > /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" "$TOKENS_DIR" install -d -m 0700 -o root -g root /etc/wireguard [[ -f "$HUB_PRIVATE_KEY" ]] || { umask 077; wg genkey > "$HUB_PRIVATE_KEY"; } chmod 0600 "$HUB_PRIVATE_KEY" { printf 'HUB_ENDPOINT=%s\n' "$HUB_ENDPOINT_VALUE" printf 'WG_PORT=%s\n' "$WG_PORT_VALUE" printf 'HUB_ADDRESS=%s\n' "$HUB_ADDRESS" printf 'MESH_CIDR=%s\n' "$MESH_CIDR" } > "$HUB_CONFIG" chmod 0600 "$HUB_CONFIG" touch "$LINKS_FILE" chmod 0600 "$LINKS_FILE" create_hub_helper "$HUB_HELPER" render "$HUB_HELPER" firewall-restore cat > /etc/sysctl.d/90-ochenstarik-smm-forward.conf <<'EOF' net.ipv4.ip_forward = 1 EOF chmod 0644 /etc/sysctl.d/90-ochenstarik-smm-forward.conf sysctl -p /etc/sysctl.d/90-ochenstarik-smm-forward.conf >/dev/null "$HUB_HELPER" firewall-restore systemctl enable --now "wg-quick@${WG_INTERFACE}.service" configure_hub_firewall_service configure_hub_sudo if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active'; then ufw allow "$WG_PORT_VALUE/udp" comment 'Server Monitor Mesh' >/dev/null ufw route allow in on "$WG_INTERFACE" out on "$WG_INTERFACE" comment 'Server Monitor Mesh routed' >/dev/null fi log "Главный Mesh Hub установлен: ${HUB_ENDPOINT_VALUE}:${WG_PORT_VALUE}/udp" log "Создать код узла: sudo ochenstarik-smm node-code ai-agent" log "Включить SSH на 2 часа: sudo ochenstarik-smm link-connect ai-agent home tcp 22 120" } 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 }' } read_join_code() { if [[ -n "${SMM_JOIN_CODE:-}" ]]; then JOIN_CODE="$SMM_JOIN_CODE" elif [[ -r /dev/tty ]]; then printf '\nНа главном сервере выполните: sudo ochenstarik-smm node-code ИМЯ\n' IFS= read -r -s -p 'Вставьте одноразовый код SMM2: ' JOIN_CODE < /dev/tty printf '\n' else die "Передайте код через SMM_JOIN_CODE" fi JOIN_CODE="${JOIN_CODE//$'\r'/}" [[ "$JOIN_CODE" == SMM2-* ]] || die "Ожидается код формата SMM2-..." } install_node_mesh() { local payload version name address token expires now private_key public_key local hub_public_key endpoint allowed_ips request_payload request_code ack expected_ack tmp ssh_port install_mesh_dependencies read_join_code payload="$(base64url_decode "${JOIN_CODE#SMM2-}")" || die "Не удалось декодировать код" version="$(payload_value "$payload" VERSION)" name="$(payload_value "$payload" NAME)" address="$(payload_value "$payload" ADDRESS)" token="$(payload_value "$payload" TOKEN)" expires="$(payload_value "$payload" EXPIRES)" hub_public_key="$(payload_value "$payload" HUB_PUBLIC_KEY)" endpoint="$(payload_value "$payload" ENDPOINT)" allowed_ips="$(payload_value "$payload" ALLOWED_IPS)" [[ "$version" == 2 ]] || die "Неподдерживаемая версия кода" [[ "$name" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]] || die "Некорректное имя узла" [[ "$address" =~ ^10\.77\.0\.([2-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])/32$ ]] \ || die "Некорректный внутренний адрес" [[ "$endpoint" =~ ^[A-Za-z0-9.-]+:[0-9]{1,5}$ ]] || die "Некорректный endpoint" [[ "$allowed_ips" == "$MESH_CIDR" ]] || die "Некорректная mesh-подсеть" [[ "$token" =~ ^[a-f0-9]{64}$ ]] || die "Некорректный enrollment token" now="$(date +%s)" [[ "$expires" =~ ^[0-9]+$ ]] && (( expires > now )) || die "Enrollment-код истёк" [[ "$(printf '%s' "$hub_public_key" | base64 -d 2>/dev/null | wc -c)" -eq 32 ]] \ || die "Некорректный публичный ключ Hub" private_key="$(wg genkey)" public_key="$(printf '%s' "$private_key" | wg pubkey)" request_payload="$(printf 'VERSION=1\nTOKEN=%s\nNAME=%s\nADDRESS=%s\nPUBLIC_KEY=%s\n' \ "$token" "$name" "$address" "$public_key")" request_code="SMMREQ1-$(printf '%s' "$request_payload" | base64url_encode)" printf '\nПриватный WireGuard-ключ создан локально и не покидает Node.\n' printf 'Request-код:\n\n%s\n\n' "$request_code" printf 'На Hub выполните: sudo ochenstarik-smm node-enroll\n' printf 'Вставьте request-код по скрытому запросу и верните полученный SMMACK1.\n\n' expected_ack="$(printf '%s|%s|%s|%s|%s|%s|%s' \ "$token" "$public_key" "$name" "$address" "$hub_public_key" "$endpoint" "$allowed_ips" \ | sha256sum | awk '{ print $1 }')" if [[ -n "${SMM_ENROLL_ACK:-}" ]]; then ack="$SMM_ENROLL_ACK" ack="${ack//$'\r'/}" [[ "$ack" == "SMMACK1-${expected_ack}" ]] \ || die "Hub не подтвердил регистрацию этого Node" elif [[ -r /dev/tty ]]; then while true; do IFS= read -r -s -p 'Вставьте ответ SMMACK1 от Hub: ' ack < /dev/tty printf '\n' ack="${ack//$'\r'/}" [[ "$ack" == "SMMACK1-${expected_ack}" ]] && break warn "Код не подтверждает этот Node. Повторите вставку или нажмите Ctrl+C." done else die "Передайте подтверждение через SMM_ENROLL_ACK" fi install -d -m 0700 -o root -g root /etc/wireguard "$MESH_DIR" tmp="$(mktemp)" { printf '[Interface]\nAddress = %s\nPrivateKey = %s\n' "$address" "$private_key" printf '\n[Peer]\nPublicKey = %s\nEndpoint = %s\n' "$hub_public_key" "$endpoint" printf 'AllowedIPs = %s\nPersistentKeepalive = 25\n' "$allowed_ips" } > "$tmp" install -m 0600 -o root -g root "$tmp" "$WG_CONFIG" rm -f -- "$tmp" printf 'ROLE=node\nNAME=%s\nADDRESS=%s\nPUBLIC_KEY=%s\n' \ "$name" "$address" "$public_key" > "$MESH_DIR/node.conf" chmod 0600 "$MESH_DIR/node.conf" unset private_key token payload request_payload JOIN_CODE SMM_JOIN_CODE SMM_ENROLL_ACK ack expected_ack systemctl enable --now "wg-quick@${WG_INTERFACE}.service" ssh_port="$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }')" if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active' && [[ -n "$ssh_port" ]]; then ufw allow in on "$WG_INTERFACE" to any port "$ssh_port" proto tcp comment 'Server Monitor Mesh SSH' >/dev/null fi if ping -c 1 -W 4 "$HUB_ADDRESS" >/dev/null 2>&1; then log "Узел $name подключён к Hub; адрес $address" else warn "Интерфейс поднят, но Hub пока не отвечает. Проверьте UDP-порт и команду sudo wg show." fi } install_node() { install_monitoring install_node_mesh log "Вторичный сервер установлен" } show_status() { local ssh_port="unknown" if command -v sshd >/dev/null 2>&1; then ssh_port="$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }')" fi printf 'Пользователь мониторинга: %s\n' "$MONITOR_USER" printf 'SSH-порт: %s\n' "${ssh_port:-unknown}" if [[ -r "$HUB_CONFIG" && -x "$HUB_HELPER" ]]; then "$HUB_HELPER" status elif [[ -r "$MESH_DIR/node.conf" ]]; then cat "$MESH_DIR/node.conf" wg show "$WG_INTERFACE" 2>/dev/null || true else printf 'ROLE=monitor-only\n' fi if [[ -x "$MONITOR_COMMAND" ]]; then runuser -u "$MONITOR_USER" -- "$MONITOR_COMMAND" else warn "Серверная часть ещё не установлена" fi } install_server_part() { install_monitoring log "Серверная часть мониторинга установлена" log "Входящий порт не изменялся, новые правила UFW не создавались" show_status } uninstall_server_part() { local 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 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 ;; 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|hub|node|hub-code NAME|status|uninstall}" ;; esac } main "$@"