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'