diff --git a/PLANS.md b/PLANS.md index 43c1fe2..f4a09a3 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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: diff --git a/README.md b/README.md index da46d54..54770ce 100644 --- a/README.md +++ b/README.md @@ -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 и не доказывают состояние реального сервера. diff --git a/tests/mocks/bin/ip b/tests/mocks/bin/ip index 5c9ca66..9379877 100755 --- a/tests/mocks/bin/ip +++ b/tests/mocks/bin/ip @@ -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: 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 diff --git a/tests/mocks/bin/iptables b/tests/mocks/bin/iptables index 5c9ca66..9b7bad3 100755 --- a/tests/mocks/bin/iptables +++ b/tests/mocks/bin/iptables @@ -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 diff --git a/tests/mocks/bin/journalctl b/tests/mocks/bin/journalctl index 5c9ca66..30283d6 100755 --- a/tests/mocks/bin/journalctl +++ b/tests/mocks/bin/journalctl @@ -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 diff --git a/tests/mocks/bin/nft b/tests/mocks/bin/nft index 5c9ca66..eee5ec2 100755 --- a/tests/mocks/bin/nft +++ b/tests/mocks/bin/nft @@ -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 diff --git a/tests/mocks/bin/ss b/tests/mocks/bin/ss index 5c9ca66..7ff3e63 100755 --- a/tests/mocks/bin/ss +++ b/tests/mocks/bin/ss @@ -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 diff --git a/tests/mocks/bin/systemctl b/tests/mocks/bin/systemctl index 5c9ca66..54cb453 100755 --- a/tests/mocks/bin/systemctl +++ b/tests/mocks/bin/systemctl @@ -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 diff --git a/tests/run.sh b/tests/run.sh index d47812b..a3a2554 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -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: 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: 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: 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 diff --git a/wg-manager.sh b/wg-manager.sh index e8a6193..4474d2a 100755 --- a/wg-manager.sh +++ b/wg-manager.sh @@ -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