feat(diagnostics): add read-only WireGuard health checks

This commit is contained in:
2026-10-13 21:37:47 +03:00
parent 75f7547add
commit 67a9e3fe03
10 changed files with 553 additions and 8 deletions
+15 -1
View File
@@ -267,7 +267,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
| M4 | DONE | Safe client creation |
| M5 | DONE | Client detail, config export, and QR display |
| M6 | DONE | Safe client removal and archival |
| M7 | NOT STARTED | Diagnostics engine and report |
| M7 | DONE | Diagnostics engine and report |
| M8 | NOT STARTED | Backup listing/restoration and audit log UX |
| M9 | NOT STARTED | TUI foundation, dashboard, and read-only screens |
| M10 | NOT STARTED | Complete TUI mutation workflows |
@@ -1649,6 +1649,20 @@ Use this template:
- Suggested commit message: `feat(clients): add safe client removal and archival`
- Next milestone: M7
## 2026-10-13 — M7 — DONE
- Implemented: read-only `diagnose [--quiet]` with stable `OK`, `WARN`, `FAIL`, and `SKIP` records; required-command, configuration and key permission, parser invariant, interface/runtime, systemd, forwarding, IPv4/IPv6 address and route, UDP listener, NAT visibility, disk/runtime peer, and bounded journal checks; plus fixture-controlled mocks and machine-friendly output.
- Coordinator: `gpt-5.6-sol` (`high`)
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
- Repair cycles: 6
- Sol review: replaced aliased shallow tests and diagnostic stubs; separated independent checks; corrected fresh-process configuration loading, exact command contracts, systemd capability detection, IPv4/IPv6 and UDP parsing, exact NAT evidence, config/key symlink and permission safety, duplicate/overlap classification, disk/runtime AllowedIPs comparison, exit codes, and read-only proof; added regression coverage for every material finding.
- Verification: `bash -n wg-manager.sh`, `bash -n tests/run.sh`, `bash -n tests/mocks/bin/*`, `bash tests/run.sh diagnostics`, `bash tests/run.sh diagnostics-systemd`, `bash tests/run.sh diagnostics-firewall`, `bash tests/run.sh diagnostics-forwarding`, `bash tests/run.sh diagnostics-runtime-diff`, `bash tests/run.sh diagnostics-read-only`, `bash tests/run.sh diagnostics-exit-codes`, `bash tests/run.sh`, `git diff --check`, permission, scope, command-contract, and secret-pattern review.
- Result: all required checks passed; diagnostics/systemd/firewall/forwarding/runtime-diff/read-only/exit-code suites passed 20/12/11/14/31/42/43 assertions, and the full regression suite passed 634 assertions. Optional `shellcheck wg-manager.sh tests/*.sh tests/mocks/bin/*` was `SKIP` because ShellCheck is unavailable.
- Decisions: none
- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, and deployment-server behavior were not accessed or tested; M7 verification is fixture- and mock-based as required. Firewall `OK` confirms only expected NAT evidence visibility, and journal/handshake data is not a complete activity history.
- Suggested commit message: `feat(diagnostics): add read-only WireGuard health checks`
- Next milestone: M8
# 8. Final completion rule
The MVP is complete only when:
+1
View File
@@ -91,3 +91,4 @@ ordinary `show`, `config`, and `qr` output remains secret-safe.
Изменение серверной конфигурации выполняется внутри транзакции: сначала конфигурация заменяется и проверяется в WireGuard, а затем финализатор архивирует связанные `.conf`, `.meta`, `.key`, `.pub` и `.psk` (если существуют) в уникальный каталог `ARCHIVE_DIR` с режимами `0700`/`0600`. Ошибка архивирования после применения приводит к откату и оставляет исходные файлы доступными для восстановления; архивы не удаляются автоматически и служат восстановительным материалом. Журнал содержит operation, target, UTC timestamp, outcome и короткий суффикс публичного ключа; ключи и содержимое конфигураций в него не записываются.
`remove` возвращает `64` для ошибки аргументов, `65` для неподтверждённой или небезопасной identity/path, `69` для отсутствующей обязательной зависимости и `70` для ошибки операции или архивации. Ошибка аудита после успешной мутации явно сообщается и возвращает операционный статус; отмена остаётся успешной без изменения состояния.
`diagnose [--quiet]` выполняет только read-only проверки конфигурации и прав, наличия интерфейса, `wg` dump, forwarding, адресов/маршрутов IPv4 и IPv6, UDP-порта, firewall, systemd, runtime-vs-disk peer set и журнала. Отдельные записи `config-parse`, `duplicate-keys`, `allowedips-overlap` и `key-permissions` не скрывают независимые причины сбоя. Каждая запись имеет один из статусов `OK`, `WARN`, `FAIL` или `SKIP`; `--quiet` выводит только стабильные строки `id=STATUS`. Обязательны `wg`, `wg-quick` и `ip`; `systemctl`, `journalctl`, `nft`, `iptables` и `ss` являются optional и при отсутствии дают собственный `SKIP`. На хосте без активного systemd проверки systemd и журнала пропускаются. `firewall=OK` означает только видимое ожидаемое NAT-свидетельство, а не полную проверку политики firewall. Ненулевой код (`70`) возвращается при любом `FAIL`, `64` — при неверных аргументах. Команда не изменяет конфигурацию, runtime, маршруты, firewall или sysctl; handshake и журнал имеют ограниченную историческую видимость. Тесты используют только изолированные fixtures/mocks и не доказывают состояние реального сервера.
+15
View File
@@ -11,5 +11,20 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 4 && $1 == link && $2 == show && $3 == dev ]]; then
[[ $4 == ${WG_MOCK_INTERFACE:-wg0} ]] || exit 99
[[ ${DIAG_IP_LINK_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_IP_LINK_OUTPUT:-$4: <UP> mtu 1420}"; exit 0
fi
if [[ $# == 5 && $1 == -o && $2 == addr && $3 == show && $4 == dev ]]; then
[[ $5 == ${WG_MOCK_INTERFACE:-wg0} ]] || exit 99
[[ ${DIAG_IP_ADDR_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_IP_ADDR_OUTPUT:-}"; exit 0
fi
if [[ $# == 6 && $1 == -o && $2 == route && $3 == show && $5 == dev ]]; then
[[ $6 == ${WG_MOCK_INTERFACE:-wg0} ]] || exit 99
[[ ${DIAG_IP_ROUTE_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_IP_ROUTE_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+4
View File
@@ -11,5 +11,9 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 3 && $1 == -t && $2 == nat && $3 == -S ]]; then
[[ ${DIAG_IPTABLES_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_IPTABLES_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+4
View File
@@ -11,5 +11,9 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 5 && $1 == -u && $2 == wg-quick@${WG_MOCK_INTERFACE:-wg0} && $3 == --since && $4 == '24 hours ago' && $5 == --no-pager ]]; then
[[ ${DIAG_JOURNAL_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_JOURNAL_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+4
View File
@@ -11,5 +11,9 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 2 && $1 == list && $2 == ruleset ]]; then
[[ ${DIAG_NFT_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_NFT_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+4
View File
@@ -11,5 +11,9 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 1 && $1 == -lun ]]; then
[[ ${DIAG_SS_FAIL:-0} == 1 ]] && exit 98
printf '%s\n' "${DIAG_SS_OUTPUT:-}"; exit 0
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+12
View File
@@ -11,5 +11,17 @@ fi
if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then
exit 0
fi
if [[ $# == 3 && $1 == is-active && $2 == -- && $3 == wg-quick@${WG_MOCK_INTERFACE:-wg0} ]]; then
case ${SYSTEMCTL_STATE:-missing} in
active|inactive|failed|unknown) printf '%s\n' "$SYSTEMCTL_STATE"; [[ $SYSTEMCTL_STATE == active || $SYSTEMCTL_STATE == unknown ]] && exit 0 || exit 3 ;;
esac
fi
if [[ $# == 1 && $1 == is-system-running ]]; then
case ${SYSTEMCTL_INIT:-missing} in
running) printf '%s\n' "$SYSTEMCTL_INIT"; exit 0 ;;
degraded) printf '%s\n' "$SYSTEMCTL_INIT"; exit 1 ;;
*) printf '%s\n' "${SYSTEMCTL_INIT:-offline}"; exit 3 ;;
esac
fi
printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2
exit 99
+314 -1
View File
@@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh"
case $# in
0) ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
esac
test_group=${1-}
@@ -2788,6 +2788,312 @@ EOF
fi
}
run_m7_tests() {
local config_file=$TEST_ROOT/diag.conf fixture=$TEST_ROOT/diag-wg0.conf output status saved_test_root
rm -rf -- "$TEST_ROOT/diag-meta" "$TEST_ROOT/diag-clients" "$TEST_ROOT/diag-backups" "$TEST_ROOT/diag-logs" "$TEST_ROOT/diag-archive" "$TEST_ROOT/diag.lock" "$TEST_ROOT/ipv4-forward" "$TEST_ROOT/runtime-diff.dump"
: >"$TEST_MOCK_LOG"
unset SYSTEMCTL_STATE DIAG_DISABLE_COMMANDS DIAG_IP_LINK_FAIL DIAG_IP_ADDR_FAIL DIAG_IP_ROUTE_FAIL DIAG_SS_FAIL DIAG_NFT_FAIL DIAG_IPTABLES_FAIL DIAG_JOURNAL_FAIL DIAG_IP_LINK_OUTPUT DIAG_IP_ADDR_OUTPUT DIAG_IP_ROUTE_OUTPUT DIAG_SS_OUTPUT DIAG_NFT_OUTPUT DIAG_IPTABLES_OUTPUT DIAG_JOURNAL_OUTPUT WG_MOCK_DUMP_SEQUENCE_DIR
cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"; chmod 600 -- "$fixture"
sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture"
write_m2_config "$config_file" "$fixture"
replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/diag-clients\""
export WG_MANAGER_TEST_MODE=1 SYSTEMCTL_INIT=running WG_MANAGER_CONFIG=$config_file WG_MANAGER_BACKUP_DIR=$TEST_ROOT/diag-backups WG_MANAGER_LOG_DIR=$TEST_ROOT/diag-logs WG_MANAGER_METADATA_DIR=$TEST_ROOT/diag-meta WG_MANAGER_ARCHIVE_DIR=$TEST_ROOT/diag-archive WG_MANAGER_LOCK_FILE=$TEST_ROOT/diag.lock
set_runtime_paths; load_config
mkdir -p -- "$TEST_ROOT/diag-meta" "$TEST_ROOT/diag-clients"; chmod 700 -- "$TEST_ROOT/diag-meta" "$TEST_ROOT/diag-clients"
printf '0\n' >"$TEST_ROOT/ipv4-forward"
export WG_MANAGER_IPV4_FORWARD_FILE=$TEST_ROOT/ipv4-forward WG_MOCK_INTERFACE=wg0 WG_MOCK_DUMP_FILE=$PROJECT_DIR/tests/fixtures/m3/runtime.dump
begin_test "diagnostics $test_group"
set +e; output=$(diagnose 2>&1); status=$?; set -e
assert_failure "$status" 'критическая диагностика возвращает ошибку'
assert_contains '[FAIL]' "$output" 'diagnostics FAIL status'
assert_contains '[SKIP]' "$output" 'diagnostics SKIP status'
assert_contains '[WARN]' "$output" 'diagnostics WARN status'
case $test_group in
diagnostics)
export DIAG_IP_LINK_OUTPUT='4: wg0: <UP> mtu 1420' DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192.0.2.1/24 scope global wg0' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev wg0 scope link' DIAG_SS_OUTPUT='udp UNCONN 0 0 0.0.0.0:51820 0.0.0.0:*'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'interface-exists=OK' "$output" 'interface existence uses exact mock argv'
assert_contains 'interface-address=OK' "$output" 'exact IPv4 address is accepted'
assert_contains 'routes=OK' "$output" 'exact route and device are accepted'
export DIAG_IP_ADDR_OUTPUT=$'4: wg0 inet 198.51.100.1/24 scope global wg0\n4: wg0 inet 192.0.2.1/24 scope global wg0'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'interface-address=OK' "$output" 'IPv4 address is found on a later ip output line'
export DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192x0x2x1/24 scope global wg0' DIAG_IP_ROUTE_OUTPUT='192.0.20.0/24 dev wg0 scope link'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'interface-address=WARN' "$output" 'lookalike IPv4 address is rejected'
assert_contains 'routes=WARN' "$output" 'lookalike IPv4 route is rejected'
export DIAG_IP_LINK_FAIL=1
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'interface-exists=FAIL' "$output" 'missing interface is FAIL'
assert_contains 'interface-runtime=OK' "$output" 'interface failure does not stop runtime check'
unset DIAG_IP_LINK_FAIL
export DIAG_DISABLE_COMMANDS=ip
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'command-ip=FAIL' "$output" 'missing required ip is FAIL'
assert_contains 'interface-address=SKIP' "$output" 'missing ip makes address check SKIP'
unset DIAG_DISABLE_COMMANDS
export DIAG_SS_OUTPUT='udp UNCONN 0 0 [::]:151820 [::]:*'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'udp-listener=WARN' "$output" 'false-positive UDP port is rejected'
export DIAG_SS_OUTPUT='udp UNCONN 0 0 [::]:51820 [::]:*'
: >"$TEST_MOCK_LOG"
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'udp-listener=OK' "$output" 'IPv6 wildcard UDP port is accepted'
assert_equal 1 "$(grep -c '^ss -lun$' "$TEST_MOCK_LOG")" 'UDP probe uses one exact ss call'
export DIAG_DISABLE_COMMANDS=ss
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'udp-listener=SKIP' "$output" 'missing ss is SKIP'
unset DIAG_DISABLE_COMMANDS
export DIAG_SS_FAIL=1 DIAG_JOURNAL_OUTPUT='Error: FAILED to activate peer'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'udp-listener=WARN' "$output" 'UDP command failure is WARN'
assert_contains 'journal=WARN' "$output" 'journal errors are case-insensitive WARN'
unset DIAG_IP_LINK_OUTPUT DIAG_IP_ADDR_OUTPUT DIAG_IP_ROUTE_OUTPUT DIAG_SS_OUTPUT DIAG_SS_FAIL DIAG_JOURNAL_OUTPUT
;;
diagnostics-systemd)
assert_contains 'systemctl is-system-running' "$(<"$TEST_MOCK_LOG")" 'systemd capability probe argv'
assert_contains 'systemctl is-active -- wg-quick@wg0' "$(<"$TEST_MOCK_LOG")" 'systemd exact read-only argv'
assert_contains 'systemd [WARN]' "$output" 'systemd uncertain state is WARN'
export SYSTEMCTL_STATE=active
set +e; output=$(diagnose 2>&1); status=$?; set -e
assert_contains 'systemd [OK]' "$output" 'active systemd is OK'
export SYSTEMCTL_STATE=failed
set +e; output=$(diagnose 2>&1); status=$?; set -e
assert_contains 'systemd [FAIL]' "$output" 'failed systemd is FAIL'
export SYSTEMCTL_INIT=degraded SYSTEMCTL_STATE=active
set +e; output=$(diagnose 2>&1); status=$?; set -e
assert_contains 'systemd [OK]' "$output" 'degraded systemd still checks service state'
export SYSTEMCTL_INIT=offline
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'systemd=SKIP' "$output" 'unsupported systemd is SKIP'
assert_contains 'journal=SKIP' "$output" 'journal is SKIP without systemd'
export SYSTEMCTL_INIT=running
unset SYSTEMCTL_STATE
;;
diagnostics-firewall)
assert_contains 'nft list ruleset' "$(<"$TEST_MOCK_LOG")" 'firewall exact read-only query'
assert_contains 'firewall [WARN]' "$output" 'firewall uncertainty is WARN'
assert_equal '' "$(grep -E ' (add|delete|insert|replace|flush| -w ) ' "$TEST_MOCK_LOG" || true)" 'firewall mock log has no mutation argv'
export DIAG_NFT_OUTPUT='oifname "wg0" ip saddr 192.0.2.0/24 masquerade'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'firewall=OK' "$output" 'nft NAT evidence is OK'
export DIAG_NFT_FAIL=0 DIAG_NFT_OUTPUT=$'oifname "wg0" ip saddr 192.0.20.0/24 masquerade\nip saddr 192.0.2.0/24 accept'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'firewall=WARN' "$output" 'separated unrelated firewall tokens are WARN'
export DIAG_NFT_OUTPUT='oifname "wg0" ip saddr 192.0.2/24' DIAG_NFT_FAIL=1 DIAG_IPTABLES_OUTPUT='-A POSTROUTING -s 192.0.2.0/24 -o wg0 -j MASQUERADE'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'firewall=OK' "$output" 'iptables fallback NAT evidence is OK'
export DIAG_DISABLE_COMMANDS=nft,iptables
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'firewall=SKIP' "$output" 'both firewall tools absent are SKIP'
unset DIAG_NFT_OUTPUT DIAG_NFT_FAIL DIAG_IPTABLES_OUTPUT DIAG_DISABLE_COMMANDS
;;
diagnostics-forwarding)
printf '1\n' >"$TEST_ROOT/ipv4-forward"
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'forwarding-ipv4=OK' "$output" 'IPv4 forwarding 1 is OK'
assert_contains 'forwarding-ipv6=SKIP' "$output" 'disabled IPv6 forwarding is SKIP'
printf 'malformed\n' >"$TEST_ROOT/ipv4-forward"
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'forwarding-ipv4=FAIL' "$output" 'malformed forwarding fails'
cp -- "$config_file" "$TEST_ROOT/ipv6-diag.conf"
replace_config_line "$TEST_ROOT/ipv6-diag.conf" 'USE_IPV6=' 'USE_IPV6="1"'
printf '1\n' >"$TEST_ROOT/ipv6-forward"
export WG_MANAGER_CONFIG=$TEST_ROOT/ipv6-diag.conf WG_MANAGER_IPV6_FORWARD_FILE=$TEST_ROOT/ipv6-forward DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192.0.2.1/24 scope global wg0 6: wg0 inet6 2001:db8:ffff::1/64 scope global' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev wg0 scope link 2001:db8:ffff::/64 dev wg0 scope link'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'forwarding-ipv6=OK' "$output" 'IPv6 forwarding 1 is OK'
assert_contains 'interface-address-ipv6=OK' "$output" 'exact IPv6 address is accepted'
assert_contains 'routes-ipv6=OK' "$output" 'exact IPv6 route is accepted'
export DIAG_IP_ADDR_OUTPUT=$'4: wg0 inet 192.0.2.1/24 scope global wg0\n6: wg0 inet6 2001:db8:ffff::1/64 scope global'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'interface-address-ipv6=OK' "$output" 'IPv6 address is found on a later ip output line'
printf '0\n' >"$TEST_ROOT/ipv6-forward"
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'forwarding-ipv6=FAIL' "$output" 'disabled IPv6 forwarding is FAIL when enabled'
printf '1\n' >"$TEST_ROOT/ipv6-forward"
export DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192.0.2.1/24 scope global wg0 6: wg0 inet6 2001:db8:ffff::10/64 scope global' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev other scope link 2001:db8:ffff::/64 dev other scope link'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'interface-address-ipv6=WARN' "$output" 'wrong IPv6 address is WARN'
assert_contains 'routes-ipv6=WARN' "$output" 'wrong IPv6 route device is WARN'
unset WG_MANAGER_IPV6_FORWARD_FILE DIAG_IP_ADDR_OUTPUT DIAG_IP_ROUTE_OUTPUT
;;
diagnostics-runtime-diff)
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_equal 1 "$(grep -c '^interface-exists=' <<<"$output")" 'interface existence has one record'
assert_equal 1 "$(grep -c '^interface-runtime=' <<<"$output")" 'runtime dump has one record'
assert_equal 1 "$(grep -c '^runtime-disk=' <<<"$output")" 'runtime-disk has one record'
assert_contains 'runtime-disk=' "$output" 'runtime-disk is independently reported'
for duplicate_fixture in duplicate-public.conf duplicate-allowed.conf overlap.conf overlap-v6.conf; do
cp -- "$PROJECT_DIR/tests/fixtures/m2/$duplicate_fixture" "$fixture"
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'config-parse=FAIL' "$output" "${duplicate_fixture} parser failure is reported"
assert_contains 'interface-exists=OK' "$output" "${duplicate_fixture} does not stop interface checks"
assert_contains 'interface-runtime=OK' "$output" "${duplicate_fixture} does not stop runtime checks"
case $duplicate_fixture in
duplicate-public.conf) assert_contains 'duplicate-keys=FAIL' "$output" 'duplicate public key has a distinct result' ;;
*) assert_contains 'allowedips-overlap=FAIL' "$output" "${duplicate_fixture} overlap has a distinct result" ;;
esac
done
cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$TEST_ROOT/runtime-diff.dump"
sed -i 's/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=/' "$TEST_ROOT/runtime-diff.dump"
export WG_MOCK_DUMP_FILE="$TEST_ROOT/runtime-diff.dump"
cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'runtime-disk=WARN' "$output" 'runtime and disk peer mismatch is WARN'
cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$TEST_ROOT/runtime-same-key.dump"
sed -i 's/192.0.2.2\/32/192.0.2.99\/32/' "$TEST_ROOT/runtime-same-key.dump"
export WG_MOCK_DUMP_FILE="$TEST_ROOT/runtime-same-key.dump"
same_key_ok=$(awk -F '\t' 'NR == 2 && $1 == "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" { print "yes" }' "$TEST_ROOT/runtime-same-key.dump")
assert_equal yes "$same_key_ok" 'same-key fixture retains the original public key'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'runtime-disk=WARN' "$output" 'same key with changed AllowedIPs is WARN'
export WG_MOCK_DUMP_FAIL_ON_CALL=1 WG_MOCK_DUMP_FAIL_COUNTER_FILE="$TEST_ROOT/runtime-fail-counter"
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'interface-runtime=FAIL' "$output" 'nonzero runtime dump is FAIL'
assert_contains 'runtime-disk=SKIP' "$output" 'runtime-disk skips unavailable runtime'
unset WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_COUNTER_FILE
printf 'malformed runtime line\n' >"$TEST_ROOT/malformed-runtime.dump"; export WG_MOCK_DUMP_FILE="$TEST_ROOT/malformed-runtime.dump"
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'interface-runtime=FAIL' "$output" 'malformed runtime dump is FAIL'
assert_contains 'runtime-disk=SKIP' "$output" 'malformed runtime skips comparison'
export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump"
;;
diagnostics-read-only)
local diag_before diag_mode config_before forward_before keys_before invalid_result
diag_before=$(sha256sum -- "$fixture"); diag_mode=$(stat -c '%a' -- "$fixture"); config_before=$(sha256sum -- "$config_file"); forward_before=$(sha256sum -- "$TEST_ROOT/ipv4-forward")
assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)|systemctl (start|stop|restart)|nft (add|delete|insert|flush)|iptables (-A|-D|-I|-F)|sysctl -w' "$TEST_MOCK_LOG" || true)" 'diagnostics has no mutation commands'
assert_equal "$diag_before" "$(sha256sum -- "$fixture")" 'diagnostics does not modify config'
assert_equal "$diag_mode" "$(stat -c '%a' -- "$fixture")" 'diagnostics preserves config mode'
set +e; local quiet_output; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_equal "$(printf '%s\n' "$quiet_output" | sed '/^$/d' | wc -l)" "$(printf '%s\n' "$quiet_output" | sed '/^$/d' | cut -d= -f1 | sort -u | wc -l)" 'quiet output has unique diagnostic ids'
assert_equal 0 "$(grep -Ev '^[A-Za-z0-9_-]+=(OK|WARN|FAIL|SKIP)$' <<<"$quiet_output" | wc -l)" 'quiet output statuses are constrained'
DIAG_IDS=(); DIAG_FAILS=0; DIAG_QUIET=1
set +e; diag_result 'bad id' OK 'ignored' >/dev/null 2>&1; status=$?; set -e
assert_failure "$status" 'invalid diagnostic id is rejected'
set +e; diag_result valid-id BOGUS 'ignored' >/dev/null 2>&1; status=$?; set -e
assert_failure "$status" 'invalid diagnostic status is rejected'
assert_equal 0 "$(grep -c '^bad id=' <<<"$quiet_output")" 'invalid diagnostic id emits no record'
diag_result valid-id OK 'first' >/dev/null
set +e; diag_result valid-id OK 'duplicate' >/dev/null 2>&1; status=$?; set -e
assert_failure "$status" 'duplicate diagnostic id is rejected'
assert_equal 3 "$DIAG_FAILS" 'diagnostic engine counts invalid and duplicate records'
export DIAG_DISABLE_COMMANDS=ss,nft,iptables,systemctl,journalctl
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'udp-listener=SKIP' "$quiet_output" 'missing optional ss is SKIP'
assert_contains 'firewall=SKIP' "$quiet_output" 'missing optional firewall tools are SKIP'
assert_contains 'systemd=SKIP' "$quiet_output" 'missing systemctl is SKIP'
assert_contains 'journal=SKIP' "$quiet_output" 'missing journal is SKIP'
export DIAG_DISABLE_COMMANDS=wg
saved_test_root=$TEST_ROOT; TEST_ROOT=''
set +e; diag_command_available wg; status=$?; set -e
assert_equal 0 "$status" 'diagnostic disable override is ignored outside confined root'
TEST_ROOT=$saved_test_root
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'command-wg=FAIL' "$quiet_output" 'missing required wg is FAIL'
unset DIAG_DISABLE_COMMANDS
assert_equal "$config_before" "$(sha256sum -- "$config_file")" 'manager config remains unchanged'
assert_equal "$forward_before" "$(sha256sum -- "$TEST_ROOT/ipv4-forward")" 'forwarding fixture remains unchanged'
printf 'KEY-PLACEHOLDER\n' >"$TEST_ROOT/diag-clients/alice.key"; chmod 600 -- "$TEST_ROOT/diag-clients/alice.key"
printf 'PSK-PLACEHOLDER\n' >"$TEST_ROOT/diag-clients/alice.psk"; chmod 600 -- "$TEST_ROOT/diag-clients/alice.psk"
keys_before=$(sha256sum -- "$TEST_ROOT/diag-clients/alice.key" "$TEST_ROOT/diag-clients/alice.psk")
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'key-permissions=OK' "$quiet_output" 'secure key artifacts are accepted'
chmod 644 -- "$TEST_ROOT/diag-clients/alice.key"
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'key-permissions=FAIL' "$quiet_output" 'permissive key artifact is rejected'
chmod 600 -- "$TEST_ROOT/diag-clients/alice.key"
mv -- "$TEST_ROOT/diag-clients/alice.psk" "$TEST_ROOT/diag-clients/alice.psk.saved"
ln -s -- "$TEST_ROOT/diag-clients/alice.psk.saved" "$TEST_ROOT/diag-clients/alice.psk"
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'key-permissions=FAIL' "$quiet_output" 'symlink key artifact is rejected'
rm -f -- "$TEST_ROOT/diag-clients/alice.psk"; mv -- "$TEST_ROOT/diag-clients/alice.psk.saved" "$TEST_ROOT/diag-clients/alice.psk"
ln -- "$TEST_ROOT/diag-clients/alice.key" "$TEST_ROOT/key-hardlink"
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'key-permissions=FAIL' "$quiet_output" 'hardlinked key artifact is rejected'
rm -f -- "$TEST_ROOT/key-hardlink"
chmod 644 -- "$config_file"; set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'config-permissions=FAIL' "$quiet_output" 'permissive manager config is reported'
chmod 600 -- "$config_file"
ln -- "$config_file" "$TEST_ROOT/config-hardlink"
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'config-permissions=FAIL' "$quiet_output" 'hardlinked manager config is rejected'
rm -f -- "$TEST_ROOT/config-hardlink"
mv -- "$config_file" "$TEST_ROOT/manager-real.conf"; ln -s -- "$TEST_ROOT/manager-real.conf" "$config_file"
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'config-readable=FAIL' "$quiet_output" 'symlinked manager config is rejected'
assert_contains 'config-parse=FAIL' "$quiet_output" 'manager symlink does not get loaded'
rm -f -- "$config_file"; mv -- "$TEST_ROOT/manager-real.conf" "$config_file"
mv -- "$fixture" "$TEST_ROOT/wg-real.conf"; ln -s -- "$TEST_ROOT/wg-real.conf" "$fixture"
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'config-permissions=FAIL' "$quiet_output" 'symlinked server config is rejected'
assert_contains 'config-parse=FAIL' "$quiet_output" 'server symlink target is not parsed'
rm -f -- "$fixture"; mv -- "$TEST_ROOT/wg-real.conf" "$fixture"
chmod 750 -- "$TEST_ROOT/diag-clients"
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'key-permissions=FAIL' "$quiet_output" 'group-accessible client directory is rejected'
chmod 700 -- "$TEST_ROOT/diag-clients"
mkdir -p -- "$TEST_ROOT/diag-outside"; printf 'outside-sentinel\n' >"$TEST_ROOT/diag-outside/sentinel"
mv -- "$TEST_ROOT/diag-clients" "$TEST_ROOT/diag-clients-real"; ln -s -- "$TEST_ROOT/diag-outside" "$TEST_ROOT/diag-clients"
set +e; quiet_output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'key-permissions=FAIL' "$quiet_output" 'symlinked client directory is rejected'
assert_equal 'outside-sentinel' "$(<"$TEST_ROOT/diag-outside/sentinel")" 'symlink target remains unread and unchanged'
rm -f -- "$TEST_ROOT/diag-clients"; mv -- "$TEST_ROOT/diag-clients-real" "$TEST_ROOT/diag-clients"
assert_equal "$keys_before" "$(sha256sum -- "$TEST_ROOT/diag-clients/alice.key" "$TEST_ROOT/diag-clients/alice.psk")" 'key artifacts remain unchanged'
invalid_result=$(grep -Ev '^(wg show [^ ]+ dump|ip link show dev [^ ]+|ip -o addr show dev [^ ]+|ip -o route show [0-9.]+/24 dev [^ ]+|ip -o route show [0-9A-Fa-f:]+/64 dev [^ ]+|systemctl is-system-running|systemctl is-active -- wg-quick@[^ ]+|ss -lun|nft list ruleset|iptables -t nat -S|journalctl -u wg-quick@[^ ]+ --since 24\\ hours\\ ago --no-pager)$' "$TEST_MOCK_LOG" || true)
assert_equal '' "$invalid_result" 'diagnostic log contains only read-only allowlisted argv'
assert_equal "$diag_before" "$(sha256sum -- "$fixture")" 'final server config snapshot is unchanged'
assert_equal "$diag_mode" "$(stat -c '%a' -- "$fixture")" 'final server config mode is unchanged'
assert_equal "$config_before" "$(sha256sum -- "$config_file")" 'final manager config snapshot is unchanged'
assert_equal 600 "$(stat -c '%a' -- "$config_file")" 'final manager config mode is unchanged'
assert_equal "$forward_before" "$(sha256sum -- "$TEST_ROOT/ipv4-forward")" 'final forwarding snapshot is unchanged'
;;
diagnostics-exit-codes)
set +e; diagnose --bad >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'unknown diagnose flag is usage'
set +e; diagnose extra >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'extra diagnose argument is usage'
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --bad 2>&1); status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'CLI unknown diagnose flag is usage'
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose extra 2>&1); status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'CLI extra diagnose argument is usage'
printf '1\n' >"$TEST_ROOT/ipv4-forward"
export DIAG_IP_LINK_OUTPUT='4: wg0: <POINTOPOINT> mtu 1420' DIAG_IP_ADDR_OUTPUT='4: wg0 inet 192.0.2.1/24 scope global wg0' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev wg0 scope link' DIAG_SS_OUTPUT='udp UNCONN 0 0 0.0.0.0:51820 0.0.0.0:*' DIAG_NFT_OUTPUT='oifname "wg0" ip saddr 192.0.2.0/24 masquerade' SYSTEMCTL_STATE=active
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
assert_equal 0 "$status" 'healthy diagnostics returns zero'
assert_contains 'firewall=OK' "$output" 'healthy firewall has NAT evidence'
for expected_id in config-readable config-permissions config-parse duplicate-keys allowedips-overlap key-permissions command-wg command-wg-quick command-ip interface-exists interface-runtime systemd forwarding-ipv4 forwarding-ipv6 interface-address routes interface-address-ipv6 routes-ipv6 udp-listener firewall runtime-disk journal; do
assert_equal 1 "$(grep -c "^${expected_id}=" <<<"$output")" "healthy output has one ${expected_id} record"
done
export WG_MANAGER_CONFIG=$config_file WG_MANAGER_IPV4_FORWARD_FILE=$TEST_ROOT/ipv4-forward WG_MOCK_INTERFACE=wg9
cp -- "$config_file" "$TEST_ROOT/nondefault.conf"
sed -i 's/WG_INTERFACE="wg0"/WG_INTERFACE="wg9"/' "$TEST_ROOT/nondefault.conf"
export WG_MANAGER_CONFIG=$TEST_ROOT/nondefault.conf
export DIAG_IP_LINK_OUTPUT='4: wg9: <UP> mtu 1420' DIAG_IP_ADDR_OUTPUT='4: wg9 inet 192.0.2.1/24 scope global wg9' DIAG_IP_ROUTE_OUTPUT='192.0.2.0/24 dev wg9 scope link' DIAG_SS_OUTPUT='udp UNCONN 0 0 0.0.0.0:51820 0.0.0.0:*' DIAG_NFT_OUTPUT='oifname "wg9" ip saddr 192.0.2.0/24 masquerade' SYSTEMCTL_INIT=running SYSTEMCTL_STATE=active
: >"$TEST_MOCK_LOG"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e
assert_equal 0 "$status" 'fresh CLI healthy non-default config returns zero'
assert_contains 'interface-exists=OK' "$output" 'fresh CLI loads non-default interface config'
assert_contains 'interface-runtime=OK' "$output" 'fresh CLI uses non-default wg argv'
assert_equal 1 "$(grep -c '^wg show wg9 dump$' "$TEST_MOCK_LOG")" 'fresh CLI emits one exact non-default wg query'
assert_equal 1 "$(grep -c '^ip link show dev wg9$' "$TEST_MOCK_LOG")" 'fresh CLI emits one exact non-default ip query'
assert_equal 1 "$(grep -c '^systemctl is-system-running$' "$TEST_MOCK_LOG")" 'fresh CLI emits one systemd capability query'
assert_equal 1 "$(grep -c '^systemctl is-active -- wg-quick@wg9$' "$TEST_MOCK_LOG")" 'fresh CLI emits one service query'
assert_equal 1 "$(grep -c '^journalctl -u wg-quick@wg9 --since 24\\ hours\\ ago --no-pager$' "$TEST_MOCK_LOG")" 'fresh CLI emits one bounded journal query'
export DIAG_IP_LINK_FAIL=1
: >"$TEST_MOCK_LOG"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e
assert_equal "$EXIT_OPERATION" "$status" 'fresh CLI critical failure returns operation status'
assert_contains 'journal=' "$output" 'fresh CLI continues after an early interface failure'
unset DIAG_IP_LINK_FAIL
unset DIAG_IP_LINK_OUTPUT DIAG_IP_ADDR_OUTPUT DIAG_IP_ROUTE_OUTPUT DIAG_SS_OUTPUT DIAG_NFT_OUTPUT SYSTEMCTL_STATE
;;
esac
if [[ $test_group == diagnostics-exit-codes ]]; then
set +e; diagnose --bad >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'неизвестный флаг diagnose'
fi
}
if [[ $test_group == config ]]; then
run_config_tests
elif [[ $test_group == show-client || $test_group == config-export || $test_group == qr || $test_group == path-safety || $test_group == no-secret-default-output ]]; then
@@ -2801,6 +3107,8 @@ elif [[ $test_group == add-client || $test_group == address-allocation || $test_
run_m4_tests
elif [[ $test_group == remove-client || $test_group == remove-cancel || $test_group == remove-exact-block || $test_group == remove-rollback || $test_group == archive-client || $test_group == archive-path-safety || $test_group == remove-preserves-neighbors ]]; then
run_m6_tests
elif [[ $test_group == diagnostics || $test_group == diagnostics-systemd || $test_group == diagnostics-firewall || $test_group == diagnostics-forwarding || $test_group == diagnostics-runtime-diff || $test_group == diagnostics-read-only || $test_group == diagnostics-exit-codes ]]; then
run_m7_tests
else
run_config_tests
run_gum_tests
@@ -2813,6 +3121,11 @@ else
run_m5_tests
test_group=''
run_m6_tests
for m7_group in diagnostics diagnostics-systemd diagnostics-firewall diagnostics-forwarding diagnostics-runtime-diff diagnostics-read-only diagnostics-exit-codes; do
test_group=$m7_group
run_m7_tests
done
unset m7_group
test_group=''
fi
+180 -6
View File
@@ -54,7 +54,7 @@ print_usage() {
Использование:
wg-manager.sh help
wg-manager.sh version
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose|logs|backups|restore|tui}
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose [--quiet]|logs|backups|restore|tui}
Команды управления WireGuard будут добавлены в следующих версиях.
USAGE
@@ -261,6 +261,8 @@ load_config() { set_runtime_paths || return $?; parse_config_file "$CONFIG_PATH"
readonly MANAGED_MARKER_PREFIX='wg-manager-client:'
declare -a CLIENT_KEYS=() CLIENT_NAMES=() CLIENT_ADDRESSES=() CLIENT_ENDPOINTS=() CLIENT_HANDSHAKES=() CLIENT_RX=() CLIENT_TX=() CLIENT_STATES=() CLIENT_ORIGINS=()
declare -A DISK_ALLOWED DISK_ENDPOINT DISK_MARKER DISK_LEGACY DISK_IPV4 DISK_IPV6
PARSE_DUPLICATE_KEYS=0
PARSE_ALLOWEDIPS_OVERLAP=0
DISK_LISTEN_PORT='-'
declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED META_FILE
declare -A RUNTIME_ENDPOINT RUNTIME_HANDSHAKE RUNTIME_RX RUNTIME_TX RUNTIME_ALLOWED
@@ -379,11 +381,11 @@ commit_disk_peer() {
local key=$1 allowed=$2
[[ -n "$key" && -n "$allowed" ]] || { die "$EXIT_VALIDATION" 'Peer не содержит PublicKey или AllowedIPs'; return $?; }
is_wireguard_key "$key" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в конфигурации'; return $?; }
[[ -z ${DISK_ALLOWED[$key]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в конфигурации'; return $?; }
[[ -z ${DISK_ALLOWED[$key]+x} ]] || { PARSE_DUPLICATE_KEYS=1; die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в конфигурации'; return $?; }
local normalized existing_key
normalized=$(normalize_allowed_ips "$allowed") || { die "$EXIT_VALIDATION" 'Некорректные или пересекающиеся AllowedIPs'; return $?; }
normalized=$(normalize_allowed_ips "$allowed") || { PARSE_ALLOWEDIPS_OVERLAP=1; die "$EXIT_VALIDATION" 'Некорректные или пересекающиеся AllowedIPs'; return $?; }
for existing_key in "${!DISK_ALLOWED[@]}"; do
allowed_sets_overlap "$normalized" "${DISK_ALLOWED[$existing_key]}" && { die "$EXIT_VALIDATION" 'Пересекающиеся AllowedIPs между peers'; return $?; }
allowed_sets_overlap "$normalized" "${DISK_ALLOWED[$existing_key]}" && { PARSE_ALLOWEDIPS_OVERLAP=1; die "$EXIT_VALIDATION" 'Пересекающиеся AllowedIPs между peers'; return $?; }
done
DISK_ALLOWED["$key"]=$normalized
local addresses; addresses=$(extract_client_addresses "${DISK_ALLOWED[$key]}")
@@ -397,7 +399,7 @@ commit_disk_peer() {
parse_wireguard_config() {
local file=$1 line trimmed section='' key value peer_key='' peer_allowed='' peer_endpoint='-' peer_marker='' peer_legacy='' pending_marker='' pending_legacy='' line_number=0
local -A interface_fields=() peer_fields=()
DISK_ALLOWED=(); DISK_ENDPOINT=(); DISK_MARKER=(); DISK_LEGACY=(); DISK_IPV4=(); DISK_IPV6=()
DISK_ALLOWED=(); DISK_ENDPOINT=(); DISK_MARKER=(); DISK_LEGACY=(); DISK_IPV4=(); DISK_IPV6=(); PARSE_DUPLICATE_KEYS=0; PARSE_ALLOWEDIPS_OVERLAP=0
DISK_LISTEN_PORT='-'
[[ -r "$file" && ! -L "$file" ]] || { die "$EXIT_VALIDATION" 'Конфигурация WireGuard недоступна или является ссылкой'; return $?; }
while IFS= read -r line || [[ -n "$line" ]]; do
@@ -1405,6 +1407,173 @@ resolve_gum() {
print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; }
run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; }
DIAG_FAILS=0
DIAG_QUIET=0
declare -A DIAG_IDS=()
diag_result() {
local ident=$1 status=$2 summary=$3 guidance=''
(( $# >= 4 )) && guidance=$4
[[ $ident =~ ^[a-z0-9-]+$ ]] || { ((DIAG_FAILS += 1)); return 1; }
[[ $status == OK || $status == WARN || $status == FAIL || $status == SKIP ]] || { ((DIAG_FAILS += 1)); return 1; }
if [[ -n ${DIAG_IDS[$ident]+x} ]]; then ((DIAG_FAILS += 1)); return 1; fi
DIAG_IDS[$ident]=1
if ((DIAG_QUIET == 1)); then printf '%s=%s\n' "$ident" "$status"; else printf '%s [%s] %s' "$ident" "$status" "$summary"; [[ -n $guidance ]] && printf ' — %s' "$guidance"; printf '\n'; fi
[[ $status == FAIL ]] && ((DIAG_FAILS += 1)); return 0
}
diag_command_available() {
local command_name=$1 disabled
local -a disabled_commands=()
if transaction_test_hooks_enabled; then
IFS=, read -r -a disabled_commands <<< "${DIAG_DISABLE_COMMANDS:-}"
for disabled in "${disabled_commands[@]:-}"; do [[ $disabled == "$command_name" ]] && return 1; done
fi
command -v -- "$command_name" >/dev/null 2>&1
}
diag_read_value() { local value; [[ -r $1 && ! -L $1 ]] || return 1; IFS= read -r value <"$1" || return 1; printf '%s' "$value"; }
diagnose() {
local manager_config_ok=0 output path value command_name dump_ok=0
local disk_peer_count=0 runtime_peer_count=0 route_output systemd_state runtime_match=1 disk_parse_ok=0 systemd_usable=0
local interface_name=wg0 server_vpn_ip=0.0.0.0 vpn_prefix=0 server_port=0 use_ipv6=0 ipv6_prefix='' ipv4_network=''
local lower_output='' line nat_ok=0 key token key_owner index
local address_ok=0 route_ok=0 ipv6_address_ok=0 ipv6_route_ok=0
local line_network=0 line_interface=0 line_nat=0
local -a tokens=()
DIAG_FAILS=0; DIAG_QUIET=0; DIAG_IDS=()
(($# <= 1)) || { die "$EXIT_USAGE" 'Лишние аргументы diagnose'; return $?; }
if (($# == 1)); then [[ $1 == --quiet ]] || { die "$EXIT_USAGE" 'Разрешён только флаг diagnose --quiet'; return $?; }; DIAG_QUIET=1; fi
set_runtime_paths || { diag_result paths FAIL 'Некорректные пути состояния' 'Проверьте WG_MANAGER_*'; return "$EXIT_VALIDATION"; }
if [[ -r $CONFIG_PATH && -f $CONFIG_PATH && ! -L $CONFIG_PATH ]] && path_components_safe "$CONFIG_PATH"; then
diag_result config-readable OK 'Конфигурация доступна'
if load_config >/dev/null 2>&1; then manager_config_ok=1; interface_name=$WG_INTERFACE; server_vpn_ip=$SERVER_VPN_IP; vpn_prefix=$VPN_SUBNET_PREFIX; server_port=$SERVER_PORT; use_ipv6=$USE_IPV6; ipv6_prefix=$IPV6_PREFIX; ipv4_network="${vpn_prefix}.0/24"; fi
else
diag_result config-readable FAIL 'Конфигурация недоступна'
fi
if ((manager_config_ok == 1)); then
local config_mode wg_mode config_owner wg_owner wg_config_safe=0
config_mode=$(stat -c '%a' -- "$CONFIG_PATH" 2>/dev/null) || config_mode=''
config_owner=$(stat -c '%u:%g' -- "$CONFIG_PATH" 2>/dev/null) || config_owner=''
if [[ -r $WG_CONF && -f $WG_CONF && ! -L $WG_CONF ]] && path_components_safe "$WG_CONF"; then wg_config_safe=1; fi
if ((wg_config_safe == 1)); then
wg_mode=$(stat -c '%a' -- "$WG_CONF" 2>/dev/null) || wg_mode=''
wg_owner=$(stat -c '%u:%g' -- "$WG_CONF" 2>/dev/null) || wg_owner=''
else
wg_mode=''; wg_owner=''
fi
if [[ -f $CONFIG_PATH && ! -L $CONFIG_PATH && ( $config_mode == 400 || $config_mode == 600 ) && ( $wg_mode == 400 || $wg_mode == 600 ) && $(stat -c '%h' -- "$CONFIG_PATH") == 1 && $wg_config_safe == 1 && $(stat -c '%h' -- "$WG_CONF") == 1 && $config_owner == "$wg_owner" ]]; then diag_result config-permissions OK 'Права конфигураций безопасны'; else diag_result config-permissions FAIL 'Небезопасные права конфигураций'; fi
if ((wg_config_safe == 1)) && parse_wireguard_config "$WG_CONF" >/dev/null 2>&1; then disk_parse_ok=1; diag_result config-parse OK 'Конфигурация WireGuard корректна'; else diag_result config-parse FAIL 'Конфигурация WireGuard не прошла проверку'; fi
if ((disk_parse_ok == 1)); then
(( PARSE_DUPLICATE_KEYS == 1 )) && diag_result duplicate-keys FAIL 'Обнаружены повторяющиеся поля' || diag_result duplicate-keys OK 'Повторяющихся ключей не найдено'
(( PARSE_ALLOWEDIPS_OVERLAP == 1 )) && diag_result allowedips-overlap FAIL 'Обнаружены дублирующиеся или пересекающиеся AllowedIPs' || diag_result allowedips-overlap OK 'AllowedIPs не пересекаются'
else
(( PARSE_DUPLICATE_KEYS == 1 )) && diag_result duplicate-keys FAIL 'Обнаружены повторяющиеся поля' || diag_result duplicate-keys SKIP 'Полный разбор конфигурации невозможен'
(( PARSE_ALLOWEDIPS_OVERLAP == 1 )) && diag_result allowedips-overlap FAIL 'Обнаружены дублирующиеся или пересекающиеся AllowedIPs' || diag_result allowedips-overlap SKIP 'Полный разбор конфигурации невозможен'
fi
local key_file key_mode key_links key_found=0 key_bad=0 client_dir_mode client_dir_owner
client_dir_mode=$(stat -c '%a' -- "$CLIENT_DIR" 2>/dev/null) || client_dir_mode=''; client_dir_owner=$(stat -c '%u:%g' -- "$CLIENT_DIR" 2>/dev/null) || client_dir_owner=''
if [[ -d $CLIENT_DIR && ! -L $CLIENT_DIR && $client_dir_mode == 700 && $client_dir_owner == "$wg_owner" ]] && path_components_safe "$CLIENT_DIR"; then
for key_file in "${CLIENT_DIR%/}"/*.key "${CLIENT_DIR%/}"/*.psk; do
[[ -e $key_file || -L $key_file ]] || continue
key_found=1
key_mode=$(stat -c '%a' -- "$key_file" 2>/dev/null) || key_mode=''; key_links=$(stat -c '%h' -- "$key_file" 2>/dev/null) || key_links=''
key_owner=$(stat -c '%u:%g' -- "$key_file" 2>/dev/null) || key_owner=''
if [[ ! -f $key_file || -L $key_file || ( $key_mode != 400 && $key_mode != 600 ) || $key_links != 1 || $key_owner != "$wg_owner" ]]; then key_bad=1; fi
done
if ((key_found == 0)); then diag_result key-permissions SKIP 'Отдельные ключевые файлы не найдены'; elif ((key_bad == 0)); then diag_result key-permissions OK 'Права ключевых файлов безопасны'; else diag_result key-permissions FAIL 'Небезопасные права ключевых файлов'; fi
else diag_result key-permissions FAIL 'Каталог клиентских ключей небезопасен'; fi
else diag_result config-permissions FAIL 'Параметры конфигурации недействительны'; diag_result config-parse FAIL 'Конфигурация менеджера не загружена'; diag_result duplicate-keys SKIP 'Конфигурация не загружена'; diag_result allowedips-overlap SKIP 'Конфигурация не загружена'; diag_result key-permissions SKIP 'Конфигурация не загружена'; fi
for command_name in wg wg-quick ip; do diag_command_available "$command_name" && diag_result "command-$command_name" OK "Команда $command_name доступна" || diag_result "command-$command_name" FAIL "Не найдена обязательная команда $command_name"; done
if ((manager_config_ok == 1)) && diag_command_available ip && ip link show dev "$interface_name" >/dev/null 2>&1; then diag_result interface-exists OK 'Интерфейс существует'; elif ((manager_config_ok == 1)); then diag_result interface-exists FAIL 'Интерфейс не найден'; else diag_result interface-exists SKIP 'Конфигурация менеджера не загружена'; fi
if ((manager_config_ok == 1)) && diag_command_available wg && output=$(wg show "$interface_name" dump 2>/dev/null) && parse_runtime_dump "$output" >/dev/null 2>&1; then dump_ok=1; diag_result interface-runtime OK 'Дамп WireGuard корректен'; elif ((manager_config_ok == 1)); then diag_result interface-runtime FAIL 'Дамп WireGuard недоступен или неверен'; else diag_result interface-runtime SKIP 'Конфигурация менеджера не загружена'; fi
if diag_command_available systemctl; then
if ! systemd_state=$(systemctl is-system-running 2>/dev/null); then :; fi
if [[ $systemd_state == running || $systemd_state == degraded ]]; then
systemd_usable=1
if systemd_state=$(systemctl is-active -- "wg-quick@$interface_name" 2>/dev/null); then
case $systemd_state in active) diag_result systemd OK 'Сервис WireGuard активен';; inactive|failed|dead) diag_result systemd FAIL 'Сервис WireGuard неактивен';; *) diag_result systemd WARN 'Состояние systemd неясно';; esac
else
case $systemd_state in inactive|failed|dead) diag_result systemd FAIL 'Сервис WireGuard неактивен';; *) diag_result systemd WARN 'Не удалось получить состояние systemd';; esac
fi
else diag_result systemd SKIP 'systemd не является активным init'; fi
else diag_result systemd SKIP 'systemctl не найден'; fi
path=${WG_MANAGER_IPV4_FORWARD_FILE:-/proc/sys/net/ipv4/ip_forward}; value=$(diag_read_value "$path" 2>/dev/null) || value=''; if [[ $value == 1 ]]; then diag_result forwarding-ipv4 OK 'IPv4 forwarding включён'; elif [[ -n $value ]]; then diag_result forwarding-ipv4 FAIL 'IPv4 forwarding имеет небезопасное значение'; else diag_result forwarding-ipv4 SKIP 'Нет доступа к IPv4 forwarding'; fi
if [[ $use_ipv6 == 1 ]]; then path=${WG_MANAGER_IPV6_FORWARD_FILE:-/proc/sys/net/ipv6/conf/all/forwarding}; value=$(diag_read_value "$path" 2>/dev/null) || value=''; [[ $value == 1 ]] && diag_result forwarding-ipv6 OK 'IPv6 forwarding включён' || [[ -n $value ]] && diag_result forwarding-ipv6 FAIL 'IPv6 forwarding имеет небезопасное значение' || diag_result forwarding-ipv6 SKIP 'Нет доступа к IPv6 forwarding'; else diag_result forwarding-ipv6 SKIP 'IPv6 отключён'; fi
if ((manager_config_ok == 1)) && diag_command_available ip; then
output=$(ip -o addr show dev "$interface_name" 2>/dev/null); address_ok=0
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do token=${token%%/*}; [[ $token == "$server_vpn_ip" ]] && address_ok=1; done
done <<<"$output"
((address_ok == 1)) && diag_result interface-address OK 'Адрес интерфейса подтверждён' || diag_result interface-address WARN 'Адрес интерфейса не подтверждён'
route_output=$(ip -o route show "$ipv4_network" dev "$interface_name" 2>/dev/null); tokens=(); read -r -a tokens <<<"$route_output"; route_ok=0
for ((index=0; index<${#tokens[@]}; index++)); do [[ ${tokens[index]} == "$ipv4_network" && ${tokens[index+1]:-} == dev && ${tokens[index+2]:-} == "$interface_name" ]] && route_ok=1; done
((route_ok == 1)) && diag_result routes OK 'Маршрут VPN подтверждён' || diag_result routes WARN 'Маршрут VPN не подтверждён'
else diag_result interface-address SKIP 'ip не найден'; diag_result routes SKIP 'ip не найден'; fi
if [[ $use_ipv6 == 1 ]] && diag_command_available ip; then
local expected_ipv6="${ipv6_prefix}1" expected_ipv6_route="${ipv6_prefix}/64"
output=$(ip -o addr show dev "$interface_name" 2>/dev/null); ipv6_address_ok=0
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do token=${token%%/*}; [[ $token == "$expected_ipv6" ]] && ipv6_address_ok=1; done
done <<<"$output"
((ipv6_address_ok == 1)) && diag_result interface-address-ipv6 OK 'IPv6 адрес интерфейса подтверждён' || diag_result interface-address-ipv6 WARN 'IPv6 адрес интерфейса не подтверждён'
route_output=$(ip -o route show "$expected_ipv6_route" dev "$interface_name" 2>/dev/null); tokens=(); read -r -a tokens <<<"$route_output"; ipv6_route_ok=0
for ((index=0; index<${#tokens[@]}; index++)); do [[ ${tokens[index]} == "$expected_ipv6_route" && ${tokens[index+1]:-} == dev && ${tokens[index+2]:-} == "$interface_name" ]] && ipv6_route_ok=1; done
((ipv6_route_ok == 1)) && diag_result routes-ipv6 OK 'IPv6 маршрут VPN подтверждён' || diag_result routes-ipv6 WARN 'IPv6 маршрут VPN не подтверждён'
else diag_result interface-address-ipv6 SKIP 'IPv6 отключён'; diag_result routes-ipv6 SKIP 'IPv6 отключён'; fi
if diag_command_available ss; then
local udp_ok=0 endpoint
if output=$(ss -lun 2>/dev/null); then
tokens=()
while IFS= read -r line; do
read -r -a tokens <<<"$line"
for token in "${tokens[@]}"; do
token=${token//\[/}; token=${token//\]/}
[[ $token == *":$server_port" ]] || continue
endpoint=${token%:"$server_port"}
if [[ $endpoint == '*' || $endpoint == '::' || $endpoint =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then udp_ok=1; fi
done
done <<<"$output"
fi
((udp_ok == 1)) && diag_result udp-listener OK 'UDP-порт подтверждён' || diag_result udp-listener WARN 'UDP-порт не подтверждён'
else diag_result udp-listener SKIP 'ss не найден'; fi
local firewall_checked=0
if diag_command_available nft; then
if output=$(nft list ruleset 2>/dev/null); then
firewall_checked=1; nat_ok=0; lower_output=${output,,}
while IFS= read -r line; do
line=${line,,}
tokens=(); read -r -a tokens <<<"$line"; line_network=0; line_interface=0; line_nat=0
for token in "${tokens[@]}"; do token=${token//\"/}; [[ $token == "$ipv4_network" ]] && line_network=1; [[ $token == "$interface_name" ]] && line_interface=1; [[ $token == masquerade || $token == snat ]] && line_nat=1; done
((line_network == 1 && line_interface == 1 && line_nat == 1)) && nat_ok=1
((nat_ok == 1)) && break
done <<<"$lower_output"
((nat_ok == 1)) && diag_result firewall OK 'Видно ожидаемое NAT-свидетельство' || diag_result firewall WARN 'Не найдено убедительное NAT-свидетельство'
fi
fi
if ((firewall_checked == 0)) && diag_command_available iptables; then
if output=$(iptables -t nat -S 2>/dev/null); then
firewall_checked=1; nat_ok=0; lower_output=${output,,}
while IFS= read -r line; do
line=${line,,}
tokens=(); read -r -a tokens <<<"$line"; line_network=0; line_interface=0; line_nat=0
for token in "${tokens[@]}"; do token=${token//\"/}; [[ $token == "$ipv4_network" ]] && line_network=1; [[ $token == "$interface_name" ]] && line_interface=1; [[ $token == masquerade || $token == snat ]] && line_nat=1; done
((line_network == 1 && line_interface == 1 && line_nat == 1)) && nat_ok=1
((nat_ok == 1)) && break
done <<<"$lower_output"
((nat_ok == 1)) && diag_result firewall OK 'Видно ожидаемое NAT-свидетельство' || diag_result firewall WARN 'Не найдено убедительное NAT-свидетельство'
fi
fi
((firewall_checked == 1)) || { if diag_command_available nft || diag_command_available iptables; then diag_result firewall WARN 'Команда firewall недоступна'; else diag_result firewall SKIP 'Нет nftables или iptables'; fi; }
if ((manager_config_ok == 1 && disk_parse_ok == 1 && dump_ok == 1)); then
disk_peer_count=${#DISK_ALLOWED[@]}; runtime_peer_count=${#RUNTIME_ALLOWED[@]};
((disk_peer_count == runtime_peer_count)) || runtime_match=0
for key in "${!DISK_ALLOWED[@]}"; do [[ -n ${RUNTIME_ALLOWED[$key]+x} && ${RUNTIME_ALLOWED[$key]} == "${DISK_ALLOWED[$key]}" ]] || runtime_match=0; done
for key in "${!RUNTIME_ALLOWED[@]}"; do [[ -n ${DISK_ALLOWED[$key]+x} ]] || runtime_match=0; done
((runtime_match == 1)) && diag_result runtime-disk OK 'Наборы peers совпадают' || diag_result runtime-disk WARN 'Наборы peers имеют различия';
else diag_result runtime-disk SKIP 'Сравнение peers невозможно'; fi
if ((systemd_usable == 1)) && diag_command_available journalctl; then if output=$(journalctl -u "wg-quick@$interface_name" --since '24 hours ago' --no-pager 2>/dev/null); then lower_output=${output,,}; [[ $lower_output == *error* || $lower_output == *failed* ]] && diag_result journal WARN 'Журнал содержит ошибки' || diag_result journal OK 'Свежих ошибок в журнале не найдено'; else diag_result journal WARN 'Журнал недоступен'; fi; else diag_result journal SKIP 'Журнал недоступен без systemd'; fi
((DIAG_FAILS == 0)) || return "$EXIT_OPERATION"
}
resolve_named_client() {
local name=$1 index count=0 found=-1
@@ -1844,6 +2013,11 @@ main() {
case $command in show) show_client "$name" ;; qr) qr_client "$name" ;; esac
return $?
fi
if [[ $1 == diagnose ]]; then
shift
diagnose "$@"
return $?
fi
if (( $# != 1 )); then
if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi
print_usage >&2
@@ -1858,7 +2032,7 @@ main() {
shift
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; }
remove_client "$@" ;;
diagnose|logs|backups|restore|tui) run_placeholder "$1" ;;
logs|backups|restore|tui) run_placeholder "$1" ;;
*)
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
print_usage >&2