795 lines
31 KiB
Bash
795 lines
31 KiB
Bash
#!/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 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
|
||
}
|
||
|
||
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"
|
||
}
|
||
|
||
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-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 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-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 <<EOF
|
||
[Unit]
|
||
Description=Ochenstarik Server Monitor mesh ACL
|
||
After=wg-quick@${WG_INTERFACE}.service
|
||
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
|
||
systemctl daemon-reload
|
||
systemctl enable --now ochenstarik-smm-firewall.service
|
||
}
|
||
|
||
configure_hub_sudo() {
|
||
cat > /etc/sudoers.d/ochenstarik-smm-hub <<EOF
|
||
${MONITOR_USER} ALL=(root) NOPASSWD: ${HUB_HELPER} *
|
||
EOF
|
||
chmod 0440 /etc/sudoers.d/ochenstarik-smm-hub
|
||
visudo -cf /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 "Включить связь: sudo ochenstarik-smm link-connect ai-agent home"
|
||
}
|
||
|
||
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 "$@"
|