diff --git a/PLANS.md b/PLANS.md index a3e2334..30fd5a8 100644 --- a/PLANS.md +++ b/PLANS.md @@ -262,7 +262,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`. |---|---|---| | M0 | DONE | Test harness and safe project skeleton | | M1 | DONE | Runtime foundation, CLI shell, and config loading | -| M2 | NOT STARTED | WireGuard parser, client model, `status`, and `list` | +| M2 | DONE | WireGuard parser, client model, `status`, and `list` | | M3 | NOT STARTED | Locking, backups, atomic apply, and rollback engine | | M4 | NOT STARTED | Safe client creation | | M5 | NOT STARTED | Client detail, config export, and QR display | @@ -1579,6 +1579,20 @@ Use this template: - Suggested commit message: `feat(core): add validated configuration and CLI foundation` - Next milestone: M2 +## 2026-10-12 — M2 — DONE + +- Implemented: non-executing persistent WireGuard and runtime dump parsers, validated per-client metadata, exact public-key/address identity matching, normalized managed/legacy/unmanaged client records and activity states, read-only `status` and deterministic plain-text `list`, plus isolated IPv4/IPv6, malformed-input, stopped-runtime, secret-safety, and no-write fixtures and tests. +- Coordinator: `gpt-5.6-sol` (`high`) +- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`) +- Repair cycles: 4 +- Sol review: corrected the stable eight-field peer dump contract and `(none)` tokens; hardened metadata identity/path validation and managed-state classification; fixed singleton/repeatable field handling, parser structure, endpoint and `FwMark` validation, marker scoping between adjacent peers, IPv6 overlap coverage, unsafe metadata-name coverage, read-only proof, and missing-`wg` test isolation so no host networking command can be selected. +- Verification: `bash -n wg-manager.sh`, `bash -n tests/*.sh`, `bash -n tests/mocks/bin/*`, `bash tests/run.sh parser`, `bash tests/run.sh status`, `bash tests/run.sh list`, `bash tests/run.sh`, `bash tests/run.sh list-redirected`, `LC_ALL=C bash tests/run.sh list-deterministic`, `git diff --check`, `git diff HEAD --check`, per-file `git diff --no-index --check /dev/null tests/fixtures/m2/*`, permission and worktree review. +- Result: all required checks passed; the full suite passed 246 assertions, focused parser/status/list suites passed 11/2/2 assertions, redirected output passed 2 assertions, and deterministic ordering passed 1 assertion. Optional `shellcheck wg-manager.sh tests/*.sh tests/mocks/bin/*` was `SKIP` because ShellCheck is unavailable. +- Decisions: none +- Known limitations: real WireGuard, networking, systemd, firewall, route, sysctl, journal, and deployment-server checks were not run; M2 verification is fixture- and mock-based as required. +- Suggested commit message: `feat(clients): add WireGuard state parsing and client listing` +- Next milestone: M3 + # 8. Final completion rule The MVP is complete only when: diff --git a/README.md b/README.md index ef501eb..8c502dc 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,17 @@ full-line `#` comments are ignored; spaces around the key, `=`, and value are trimmed. Values are either one unquoted token or a double-quoted value without escapes, and only documented keys are accepted. +For deterministic read-only tests, `WG_MANAGER_NOW` may override the current +epoch when set to decimal seconds; invalid values are rejected. + +M2 adds read-only `status` and `list`. They parse the configured WireGuard file +and, when available, `wg show dump`; a stopped or unavailable +runtime is reported as unavailable and disk-only peers remain visible. `list` +uses stable tab-separated output with English machine fields `state` and +`origin` (`managed`, `legacy`, or `unmanaged`) and never prints private or +preshared keys. + +The explicit peer comment marker is `# wg-manager-client: NAME`; it is only a +display candidate. A peer is considered managed only when its exact public key +and configured addresses match a valid metadata record with `MANAGED=1`. + diff --git a/tests/fixtures/m2/duplicate-allowed.conf b/tests/fixtures/m2/duplicate-allowed.conf new file mode 100644 index 0000000..2a480cc --- /dev/null +++ b/tests/fixtures/m2/duplicate-allowed.conf @@ -0,0 +1,8 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32,192.0.2.2/32 diff --git a/tests/fixtures/m2/duplicate-public.conf b/tests/fixtures/m2/duplicate-public.conf new file mode 100644 index 0000000..42b0231 --- /dev/null +++ b/tests/fixtures/m2/duplicate-public.conf @@ -0,0 +1,12 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32 + +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.3/32 diff --git a/tests/fixtures/m2/malformed.conf b/tests/fixtures/m2/malformed.conf new file mode 100644 index 0000000..cd087a9 --- /dev/null +++ b/tests/fixtures/m2/malformed.conf @@ -0,0 +1,7 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +AllowedIPs = 192.0.2.2/32 diff --git a/tests/fixtures/m2/managed.conf b/tests/fixtures/m2/managed.conf new file mode 100644 index 0000000..70ba371 --- /dev/null +++ b/tests/fixtures/m2/managed.conf @@ -0,0 +1,10 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +# wg-manager-client: alice +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32 +Endpoint = vpn.example.invalid:51820 diff --git a/tests/fixtures/m2/markers-adjacent.conf b/tests/fixtures/m2/markers-adjacent.conf new file mode 100644 index 0000000..c589159 --- /dev/null +++ b/tests/fixtures/m2/markers-adjacent.conf @@ -0,0 +1,16 @@ +[Interface] +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +ListenPort = 51820 + +### Client first +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32 +### Client second +[Peer] +PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= +AllowedIPs = 192.0.2.3/32 +# wg-manager-client: third +[Peer] +PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD= +AllowedIPs = 192.0.2.4/32 diff --git a/tests/fixtures/m2/multiple-managed.conf b/tests/fixtures/m2/multiple-managed.conf new file mode 100644 index 0000000..39e9fd8 --- /dev/null +++ b/tests/fixtures/m2/multiple-managed.conf @@ -0,0 +1,12 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32, 2001:db8::2/128 + +[Peer] +PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= +AllowedIPs = 192.0.2.3/32 diff --git a/tests/fixtures/m2/multiple-reversed.conf b/tests/fixtures/m2/multiple-reversed.conf new file mode 100644 index 0000000..2083102 --- /dev/null +++ b/tests/fixtures/m2/multiple-reversed.conf @@ -0,0 +1,18 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD= +AllowedIPs = 192.0.2.4/32 + +[Peer] +### Client legacy +PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= +AllowedIPs = 192.0.2.3/32,2001:db8::3/128 + +[Peer] +# wg-manager-client: alice +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32 diff --git a/tests/fixtures/m2/multiple.conf b/tests/fixtures/m2/multiple.conf new file mode 100644 index 0000000..f5abfa2 --- /dev/null +++ b/tests/fixtures/m2/multiple.conf @@ -0,0 +1,18 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +# wg-manager-client: alice +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32 + +[Peer] +### Client legacy +PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= +AllowedIPs = 192.0.2.3/32,2001:db8::3/128 + +[Peer] +PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD= +AllowedIPs = 192.0.2.4/32 diff --git a/tests/fixtures/m2/no-peers.conf b/tests/fixtures/m2/no-peers.conf new file mode 100644 index 0000000..5794026 --- /dev/null +++ b/tests/fixtures/m2/no-peers.conf @@ -0,0 +1,4 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= diff --git a/tests/fixtures/m2/overlap-v6.conf b/tests/fixtures/m2/overlap-v6.conf new file mode 100644 index 0000000..1b0da94 --- /dev/null +++ b/tests/fixtures/m2/overlap-v6.conf @@ -0,0 +1,12 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 2001:db8::/64 + +[Peer] +PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= +AllowedIPs = 2001:db8::2/128 diff --git a/tests/fixtures/m2/overlap.conf b/tests/fixtures/m2/overlap.conf new file mode 100644 index 0000000..6b24baf --- /dev/null +++ b/tests/fixtures/m2/overlap.conf @@ -0,0 +1,12 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.0/24 + +[Peer] +PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= +AllowedIPs = 192.0.2.3/32 diff --git a/tests/fixtures/m2/pre-peer-marker.conf b/tests/fixtures/m2/pre-peer-marker.conf new file mode 100644 index 0000000..764e9e4 --- /dev/null +++ b/tests/fixtures/m2/pre-peer-marker.conf @@ -0,0 +1,9 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +# wg-manager-client: alice +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32, 2001:db8::2/128 diff --git a/tests/fixtures/m2/repeatable-interface.conf b/tests/fixtures/m2/repeatable-interface.conf new file mode 100644 index 0000000..94f7c4c --- /dev/null +++ b/tests/fixtures/m2/repeatable-interface.conf @@ -0,0 +1,15 @@ +[Interface] +Address = 192.0.2.1/24 +Address = 2001:db8::1/64 +DNS = 192.0.2.53 +DNS = 2001:db8::53 +PreUp = true +PreUp = true +PostUp = true +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= +ListenPort = 51820 +FwMark = 123 + +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32 diff --git a/tests/fixtures/m2/runtime.dump b/tests/fixtures/m2/runtime.dump new file mode 100644 index 0000000..9b9809d --- /dev/null +++ b/tests/fixtures/m2/runtime.dump @@ -0,0 +1,2 @@ +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= 51820 0 +BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 1700000100 100 200 25 diff --git a/tests/mocks/bin/wg b/tests/mocks/bin/wg index 8ca9618..58ea492 100755 --- a/tests/mocks/bin/wg +++ b/tests/mocks/bin/wg @@ -6,6 +6,10 @@ allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-} printf '%q' "$command_name" >>"$TEST_MOCK_LOG" for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done printf '\n' >>"$TEST_MOCK_LOG" +if [[ $# == 3 && $1 == show && -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" && $3 == dump && -n ${WG_MOCK_DUMP_FILE:-} && -f $WG_MOCK_DUMP_FILE ]]; then + cat -- "$WG_MOCK_DUMP_FILE" + exit 0 +fi if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2 exit 99 diff --git a/tests/run.sh b/tests/run.sh index b28b1bf..03e137b 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh" case $# in 0) ;; - 1) [[ $1 == config ]] || { printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64; } ;; + 1) case $1 in config|parser|status|list|list-redirected|list-deterministic) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac test_group=${1-} @@ -156,7 +156,7 @@ run_config_tests() { begin_test 'known command validates config then reports not implemented' set +e - output=$("${PROJECT_DIR}/wg-manager.sh" status 2>&1) + output=$("${PROJECT_DIR}/wg-manager.sh" add 2>&1) status=$? set -e assert_equal "$EXIT_OPERATION" "$status" 'статус команды-заглушки' @@ -412,6 +412,450 @@ run_debug_tests() { assert_contains 'Отладка: visible' "$(<"${TEST_ROOT}/debug.stderr")" 'debug stderr' } +write_m2_config() { + local output_file=$1 wireguard_file=$2 + write_valid_config "$output_file" + replace_config_line "$output_file" 'WG_CONF=' "WG_CONF=\"${wireguard_file}\"" +} + +write_alice_metadata() { + mkdir -p "$METADATA_DIR" + cat >"${METADATA_DIR}/alice.meta" <"${METADATA_DIR}/bob.meta" <"$TEST_MOCK_LOG" + unset WG_MOCK_DUMP_FILE WG_MOCK_INTERFACE + + begin_test 'persistent WireGuard parser and trusted metadata identity' + write_alice_metadata + parse_wireguard_config "$fixture" + assert_equal 1 "${#DISK_ALLOWED[@]}" 'один peer на диске' + assert_equal 192.0.2.2/32 "${DISK_IPV4[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'адрес peer' + parse_metadata + assert_equal alice "${META_NAME[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'имя из метаданных' + assert_equal 1 "${META_MANAGED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'доверенная метка managed' + assert_equal "${CLIENT_DIR}/alice.conf" "${META_CONFIG[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'путь клиентского конфига' + assert_equal 1700000000 "${META_CREATED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'время создания метаданных' + + begin_test 'two managed peers match exact keys and addresses' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-managed.conf" "$fixture" + write_bob_metadata + printf 'IPV6=2001:db8::2\n' >>"${METADATA_DIR}/alice.meta" + load_client_model + local managed_count=0 + for record_index in "${!CLIENT_KEYS[@]}"; do [[ ${CLIENT_ORIGINS[$record_index]} == managed ]] && ((managed_count += 1)); done + assert_equal 2 "$managed_count" 'две управляемые записи' + rm -- "${METADATA_DIR}/bob.meta" + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + + begin_test 'pre-peer marker and spaced mixed AllowedIPs are parsed' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/pre-peer-marker.conf" "$fixture" + parse_wireguard_config "$fixture" + assert_equal alice "${DISK_MARKER[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'маркер перед peer' + assert_equal '192.0.2.2/32,2001:db8::2/128' "${DISK_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'нормализация AllowedIPs' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + + begin_test 'repeatable interface fields and duplicate peer singleton checks' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/repeatable-interface.conf" "$fixture" + parse_wireguard_config "$fixture" + replace_config_line "$fixture" 'FwMark =' 'FwMark = 0xca6c' + parse_wireguard_config "$fixture" + for bad_fwmark in 0x100000000 0xnope 4294967296; do + replace_config_line "$fixture" 'FwMark =' "FwMark = ${bad_fwmark}" + set +e + parse_wireguard_config "$fixture" >/dev/null 2>&1 + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" "некорректный FwMark ${bad_fwmark}" + done + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + printf 'Endpoint = vpn.example.invalid:51820\n' >>"$fixture" + set +e + output=$(parse_wireguard_config "$fixture" 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'дублирующийся Endpoint' + assert_contains 'Дублирующийся Endpoint' "$output" 'диагностика duplicate Endpoint' + for duplicate_field in PresharedKey AllowedIPs PersistentKeepalive; do + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + case $duplicate_field in + PresharedKey) printf 'PresharedKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nPresharedKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n' >>"$fixture" ;; + AllowedIPs) printf 'AllowedIPs = 192.0.2.2/32\nAllowedIPs = 192.0.2.2/32\n' >>"$fixture" ;; + PersistentKeepalive) printf 'PersistentKeepalive = 25\nPersistentKeepalive = 25\n' >>"$fixture" ;; + esac + set +e + output=$(parse_wireguard_config "$fixture" 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" "дубликат ${duplicate_field}" + done + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + printf 'Endpoint = vpn.example.invalid\n' >>"$fixture" + set +e + output=$(parse_wireguard_config "$fixture" 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'Endpoint без порта' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + printf 'PresharedKey = (none)\n' >>"$fixture" + set +e + output=$(parse_wireguard_config "$fixture" 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'persistent PSK token отвергнут' + + begin_test 'section ordering and marker leakage are rejected safely' + printf '# wg-manager-client: leaked\n[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nListenPort = 51820\n[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nAllowedIPs = 192.0.2.2/32\n' >"${TEST_ROOT}/marker-leak.conf" + parse_wireguard_config "${TEST_ROOT}/marker-leak.conf" + assert_equal '' "${DISK_MARKER[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]:-}" 'маркер не протёк через Interface' + for invalid_section in peer-before-interface empty-config repeated-interface; do + case $invalid_section in + peer-before-interface) printf '[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nAllowedIPs = 192.0.2.2/32\n' >"${TEST_ROOT}/${invalid_section}.conf" ;; + empty-config) : >"${TEST_ROOT}/${invalid_section}.conf" ;; + repeated-interface) printf '[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n[Interface]\nListenPort = 51820\n' >"${TEST_ROOT}/${invalid_section}.conf" ;; + esac + set +e + parse_wireguard_config "${TEST_ROOT}/${invalid_section}.conf" >/dev/null 2>&1 + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" "секция ${invalid_section}" + done + + begin_test 'adjacent conventional markers attach to the next peer' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/markers-adjacent.conf" "$fixture" + parse_wireguard_config "$fixture" + assert_equal first "${DISK_LEGACY[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'первый marker' + assert_equal second "${DISK_LEGACY[CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=]}" 'соседний marker' + assert_equal third "${DISK_MARKER[DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=]}" 'соседний explicit marker' + + begin_test 'metadata names remain restricted and cannot create paths' + rm -- "${METADATA_DIR}/alice.meta" + local unsafe_name metadata_output + for unsafe_name in 'bad name' '*glob?' '-name' $'bad\nname'; do + printf 'NAME=%s\nPUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nIPV4=192.0.2.2\nCREATED_AT=1700000000\nCONFIG_PATH=%s/bad.conf\nCLIENT_MODE=full-tunnel\nMANAGED=1\n' "$unsafe_name" "$CLIENT_DIR" >"${METADATA_DIR}/bad.meta" + set +e + metadata_output=$(parse_metadata 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" "unsafe metadata name ${unsafe_name@Q}" + assert_equal '' "$(printf '%s' "$metadata_output" | grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|PrivateKey|PresharedKey' || true)" 'metadata error without key material' + assert_equal 0 "$( [[ ! -e "${CLIENT_DIR}/${unsafe_name}.conf" ]] && printf 0 || printf 1 )" 'unsafe metadata does not create path' + rm -- "${METADATA_DIR}/bad.meta" + done + write_alice_metadata + + begin_test 'duplicate metadata identity is rejected' + cp -- "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/duplicate.meta" + set +e + output=$(parse_metadata 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'дубликат метаданных' + assert_contains 'Дублирующийся' "$output" 'диагностика дубликата метаданных' + rm -- "${METADATA_DIR}/duplicate.meta" + + begin_test 'empty peer list remains a valid disk model' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/no-peers.conf" "$fixture" + rm -- "${METADATA_DIR}/alice.meta" + unset WG_MOCK_DUMP_FILE + load_client_model + assert_equal 0 "${#CLIENT_KEYS[@]}" 'нет peers' + write_alice_metadata + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + + begin_test 'metadata address mismatch never establishes managed identity' + replace_config_line "${METADATA_DIR}/alice.meta" 'IPV4=' 'IPV4=192.0.2.99' + load_client_model + assert_equal legacy "${CLIENT_ORIGINS[0]}" 'mismatch origin' + write_alice_metadata + + begin_test 'legacy and unmanaged origins remain distinct from managed identity' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture" + rm -- "${METADATA_DIR}/alice.meta" + unset WG_MOCK_DUMP_FILE + load_client_model + local record_index + for record_index in "${!CLIENT_KEYS[@]}"; do + case ${CLIENT_KEYS[$record_index]} in + BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=) assert_equal legacy "${CLIENT_ORIGINS[$record_index]}" 'peer с явным marker без метаданных'; assert_equal unmanaged "${CLIENT_STATES[$record_index]}" 'marker без доверенной метадаты' ;; + CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=) assert_equal legacy "${CLIENT_ORIGINS[$record_index]}" 'legacy peer'; assert_equal unmanaged "${CLIENT_STATES[$record_index]}" 'legacy state' ;; + DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=) assert_equal unmanaged "${CLIENT_ORIGINS[$record_index]}" 'unmanaged peer' ;; + esac + done + + begin_test 'runtime parser and exact wg dump command' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + write_alice_metadata + cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" + export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 + : >"$TEST_MOCK_LOG" + load_client_model + assert_equal 1 "$RUNTIME_AVAILABLE" 'runtime доступен' + assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'порт runtime' + assert_equal active "${CLIENT_STATES[0]}" 'активный peer на границе threshold' + local runtime_log + runtime_log=$(<"$TEST_MOCK_LOG") + runtime_log+=$'\n' + assert_equal $'wg show wg0 dump\n' "$runtime_log" 'точный argv wg' + assert_equal '' "$(printf '%s' "$runtime_log" | grep -E 'systemctl|ip|gum' || true)" 'runtime без лишних команд' + begin_test 'runtime unavailable tokens normalize safely' + parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t(none)\t(none)\t0\t0\t0\t0' + assert_equal - "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'endpoint none' + assert_equal - "${RUNTIME_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'allowed none' + parse_runtime_dump $'(none)\t(none)\t51820\t0' + assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'interface none tokens' + set +e + output=$(parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid\t192.0.2.2/32\t0\t0\t0\t0' 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'runtime endpoint without port' + parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t[2001:db8::1]:51820\t192.0.2.2/32\t0\t0\t0\t0' + assert_equal '[2001:db8::1]:51820' "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'runtime IPv6 endpoint' + + begin_test 'unavailable runtime command degrades safely' + local saved_path=$PATH + local safe_runtime_path="${TEST_ROOT}/no-wg" + mkdir -p "$safe_runtime_path" + ln -s -- "$(type -P sort)" "${safe_runtime_path}/sort" + ln -s -- "$(type -P paste)" "${safe_runtime_path}/paste" + PATH=$safe_runtime_path + if command -v wg >/dev/null 2>&1; then + record_failure 'контролируемый PATH неожиданно содержит wg' + fi + unset WG_MOCK_DUMP_FILE + output=$(/bin/bash "${PROJECT_DIR}/wg-manager.sh" status) + PATH=$saved_path + assert_contains 'недоступен' "$output" 'wg отсутствует' + export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 + + begin_test 'active threshold, inactive, and never states are deterministic' + replace_config_line "$runtime_file" 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' $'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tvpn.example.invalid:51820\t192.0.2.2/32\t1699999919\t100\t200\t25' + load_client_model + assert_equal inactive "${CLIENT_STATES[0]}" 'peer just beyond threshold' + replace_config_line "$runtime_file" 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' $'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t100\t200\t25' + load_client_model + assert_equal never "${CLIENT_STATES[0]}" 'peer without handshake' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" + + begin_test 'runtime-only peer is retained as unmanaged' + printf '%s\t%s\t-\t192.0.2.9/32\t1700000100\t1\t2\t0\n' 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=' '(none)' >>"$runtime_file" + load_client_model + local runtime_index runtime_origin='' + assert_contains EEEEEEEE "$(printf '%s\n' "${CLIENT_KEYS[@]}")" 'runtime-only key parsed' + for runtime_index in "${!CLIENT_KEYS[@]}"; do + [[ ${CLIENT_KEYS[$runtime_index]} == EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= ]] && runtime_origin=${CLIENT_ORIGINS[$runtime_index]} + done + assert_equal unmanaged "$runtime_origin" 'runtime-only origin' + + begin_test 'malformed runtime dump fails without exposing fields' + printf 'malformed runtime line\n' >"$runtime_file" + set +e + output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'malformed runtime status' + assert_contains 'wg dump' "$output" 'malformed runtime diagnostic' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" + + begin_test 'status is useful without gum or systemd' + local model_before model_after + model_before=$(snapshot_model_inputs "$config_file" "$fixture" "$METADATA_DIR" "$runtime_file" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" "$LOCK_FILE") + output=$("${PROJECT_DIR}/wg-manager.sh" status 2>&1) + assert_contains 'Интерфейс: wg0' "$output" 'status interface' + assert_contains 'Управляемые: 1' "$output" 'status managed count' + assert_contains 'Активные: 1' "$output" 'status active count' + output=$("${PROJECT_DIR}/wg-manager.sh" list) + model_after=$(snapshot_model_inputs "$config_file" "$fixture" "$METADATA_DIR" "$runtime_file" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR" "$LOCK_FILE") + assert_equal "$model_before" "$model_after" 'status/list не изменяют состояние' + + begin_test 'invalid deterministic clock fails without secrets' + WG_MANAGER_NOW=invalid + set +e + output=$("${PROJECT_DIR}/wg-manager.sh" status 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'некорректные часы' + assert_equal '' "$(printf '%s' "$output" | grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' || true)" 'ошибка часов без ключей' + WG_MANAGER_NOW=1700000100 + + begin_test 'list is deterministic and secret-free' + output=$("${PROJECT_DIR}/wg-manager.sh" list) + assert_contains $'NAME\tADDRESSES\tPUBLIC_KEY' "$output" 'list header' + assert_contains $'alice\t192.0.2.2/32\tBBBBBBBB' "$output" 'list managed row' + assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' <<<"$output" || true)" 'list не показывает секретные поля' + + begin_test 'redirected list remains plain text' + "${PROJECT_DIR}/wg-manager.sh" list >"${TEST_ROOT}/list.out" + assert_contains $'alice\t192.0.2.2/32' "$(<"${TEST_ROOT}/list.out")" 'redirected list row' + assert_equal '' "$(LC_ALL=C grep -nP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]' "${TEST_ROOT}/list.out" || true)" 'redirected list without controls' + + begin_test 'list ordering is independent of peer input order' + rm -- "${METADATA_DIR}/alice.meta" + unset WG_MOCK_DUMP_FILE + cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture" + output=$("${PROJECT_DIR}/wg-manager.sh" list) + cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-reversed.conf" "$fixture" + assert_equal "$output" "$("${PROJECT_DIR}/wg-manager.sh" list)" 'стабильная сортировка list' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + write_alice_metadata + export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 + + begin_test 'stopped runtime degrades to disk-only output' + unset WG_MOCK_DUMP_FILE + output=$("${PROJECT_DIR}/wg-manager.sh" status) + assert_contains 'недоступен' "$output" 'runtime unavailable' + assert_contains 'Peers: 1' "$output" 'disk peer count' + + begin_test 'malformed persistent config fails safely' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/malformed.conf" "$fixture" + set +e + output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'malformed config status' + assert_contains 'PublicKey' "$output" 'malformed config diagnostic' + + begin_test 'duplicate AllowedIPs are rejected' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/duplicate-allowed.conf" "$fixture" + set +e + output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'duplicate AllowedIPs status' + assert_contains 'AllowedIPs' "$output" 'duplicate AllowedIPs diagnostic' + + begin_test 'duplicate public keys and overlapping networks are rejected' + for invalid_fixture in duplicate-public overlap overlap-v6; do + cp -- "${PROJECT_DIR}/tests/fixtures/m2/${invalid_fixture}.conf" "$fixture" + set +e + output=$("${PROJECT_DIR}/wg-manager.sh" list 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" "${invalid_fixture} status" + if [[ $invalid_fixture == duplicate-public ]]; then assert_contains 'PublicKey' "$output" "${invalid_fixture} diagnostic"; else assert_contains 'AllowedIPs' "$output" "${invalid_fixture} diagnostic"; fi + done +} + +run_m2_focused() { + local group=$1 config_file="${TEST_ROOT}/focused-m2.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" output reversed_output status + cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" + write_m2_config "$config_file" "$fixture" + export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/clients" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100 + set_runtime_paths + WG_CONF=$fixture + mkdir -p "$METADATA_DIR" + load_config + write_alice_metadata + case $group in + parser) + begin_test 'focused persistent and runtime parser' + parse_wireguard_config "$fixture" + parse_metadata + cp -- "${PROJECT_DIR}/tests/fixtures/m2/repeatable-interface.conf" "$fixture" + replace_config_line "$fixture" 'FwMark =' 'FwMark = 0xca6c' + parse_wireguard_config "$fixture" + assert_equal 1 "${#DISK_ALLOWED[@]}" 'hex FwMark не ломает parser' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/markers-adjacent.conf" "$fixture" + parse_wireguard_config "$fixture" + assert_equal second "${DISK_LEGACY[CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=]}" 'focused marker scoping' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" + parse_runtime_dump "$(<"$runtime_file")" + assert_equal 1 "$RUNTIME_AVAILABLE" 'runtime parser доступен' + assert_equal 51820 "$RUNTIME_LISTEN_PORT" 'порт интерфейса runtime' + assert_equal 1700000100 "${RUNTIME_HANDSHAKE[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'handshake peer' + assert_equal 100 "${RUNTIME_RX[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'rx peer' + assert_equal 200 "${RUNTIME_TX[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'tx peer' + assert_equal '192.0.2.2/32' "${RUNTIME_ALLOWED[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'allowed ips peer' + parse_runtime_dump $'(none)\t(none)\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\t[2001:db8::1]:51820\t192.0.2.2/32\t0\t0\t0\t0' + assert_equal '[2001:db8::1]:51820' "${RUNTIME_ENDPOINT[BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=]}" 'focused runtime endpoint' + set +e + output=$(parse_runtime_dump $'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\tmalformed\t(none)\t(none)\t0\t0\t0\t0' 2>&1) + status=$? + set -e + assert_equal "$EXIT_VALIDATION" "$status" 'malformed PSK отвергнут' + assert_contains 'ключ' "$output" 'диагностика malformed PSK' + ;; + status) + begin_test 'focused status output' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" + export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 + output=$("${PROJECT_DIR}/wg-manager.sh" status) + assert_contains 'Интерфейс: wg0' "$output" 'status interface' + assert_contains 'Управляемые: 1' "$output" 'status managed' + ;; + list) + begin_test 'focused list output' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" + export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 + output=$("${PROJECT_DIR}/wg-manager.sh" list) + assert_contains $'alice\t192.0.2.2/32' "$output" 'list row' + assert_equal '' "$(printf '%s' "$output" | grep -E 'PrivateKey|PresharedKey' || true)" 'list secret-free' + ;; + list-redirected) + begin_test 'focused redirected list' + cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file" + export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0 + "${PROJECT_DIR}/wg-manager.sh" list >"${TEST_ROOT}/focused-list.out" 2>"${TEST_ROOT}/focused-list.err" + assert_contains $'alice\t192.0.2.2/32' "$(<"${TEST_ROOT}/focused-list.out")" 'redirected row' + assert_equal '' "$(<"${TEST_ROOT}/focused-list.err")" 'redirected stderr empty' + ;; + list-deterministic) + begin_test 'focused deterministic list ordering' + unset WG_MOCK_DUMP_FILE + cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple.conf" "$fixture" + output=$("${PROJECT_DIR}/wg-manager.sh" list) + cp -- "${PROJECT_DIR}/tests/fixtures/m2/multiple-reversed.conf" "$fixture" + reversed_output=$("${PROJECT_DIR}/wg-manager.sh" list) + assert_equal "$output" "$reversed_output" 'порядок list стабилен' + ;; + esac +} + if [[ -z $test_group ]]; then begin_test 'sourcing entry point does not execute main' source_output='' @@ -504,16 +948,42 @@ scan_test_sources_for_host_path() { scan_path "$TESTS_DIR" } +scan_sources_for_absolute_external_commands() { + local command_name candidate path line + for command_name in wg wg-quick ip systemctl nft iptables journalctl sysctl; do + candidate='/bin' + candidate+="/${command_name}" + for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do + [[ -f "$path" ]] || continue + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"\"$candidate"* || "$line" == *"'$candidate"* || "$line" == *"=$candidate"* ]]; then record_failure "абсолютный внешний вызов в ${path}: ${candidate}"; break; fi + done <"$path" + done + candidate='/usr' + candidate+="/bin/${command_name}" + for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do + [[ -f "$path" ]] || continue + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"\"$candidate"* || "$line" == *"'$candidate"* || "$line" == *"=$candidate"* ]]; then record_failure "абсолютный внешний вызов в ${path}: ${candidate}"; break; fi + done <"$path" + done + done +} + scan_test_sources_for_host_path +scan_sources_for_absolute_external_commands fi if [[ $test_group == config ]]; then run_config_tests +elif [[ $test_group == parser || $test_group == status || $test_group == list || $test_group == list-redirected || $test_group == list-deterministic ]]; then + run_m2_focused "$test_group" else run_config_tests run_gum_tests run_dependency_tests run_debug_tests + run_m2_tests fi finish_tests diff --git a/wg-manager.sh b/wg-manager.sh index 43f2c63..b8a2a49 100755 --- a/wg-manager.sh +++ b/wg-manager.sh @@ -187,6 +187,46 @@ validate_endpoint() { done } +validate_endpoint_value() { + local value=$1 host port + if [[ $value == \[*\]:* ]]; then + host=${value%%\]:*}; host=${host#\[}; port=${value##*:} + validate_ipv6 "$host" && is_uint "$port" && (( 10#$port >= 1 && 10#$port <= 65535 )) + elif [[ $value == *:* && $value != *::* ]]; then + host=${value%:*}; port=${value##*:} + validate_endpoint "$host" && is_uint "$port" && (( 10#$port >= 1 && 10#$port <= 65535 )) + else + validate_endpoint "$value" + fi +} + +validate_peer_endpoint_value() { + local value=$1 + if [[ $value == \[*\]:* ]]; then + validate_endpoint_value "$value" + elif [[ $value != *:*:* && $value == *:* ]]; then + validate_endpoint_value "$value" + else + return 1 + fi +} + +is_fwmark() { + local value=$1 number digits + if [[ $value =~ ^[0-9]+$ ]]; then + digits=$value + while [[ ${#digits} -gt 1 && ${digits:0:1} == 0 ]]; do digits=${digits:1}; done + (( ${#digits} <= 10 )) || return 1 + (( 10#$digits <= 4294967295 )) + return + fi + [[ $value =~ ^0[xX][0-9A-Fa-f]+$ ]] || return 1 + digits=${value#0x}; digits=${digits#0X} + (( ${#digits} <= 8 )) || return 1 + number=$((value)) + (( number <= 4294967295 )) +} + validate_path() { [[ $1 == /* && $1 != *[[:space:]]* && $1 != *[$'\001'-$'\037'$'\177']* && $1 != *'['* && $1 != *']'* && $1 != *//* && $1 != */./* && $1 != */../* && $1 != */.. && $1 != */. ]]; } validate_config_values() { @@ -213,6 +253,324 @@ validate_config_values() { load_config() { set_runtime_paths || return $?; parse_config_file "$CONFIG_PATH" || return $?; require_config_keys || return $?; validate_config_values || return $?; } +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 +declare -A META_NAME META_IPV4 META_IPV6 META_CONFIG META_MODE META_MANAGED META_CREATED +declare -A RUNTIME_ENDPOINT RUNTIME_HANDSHAKE RUNTIME_RX RUNTIME_TX RUNTIME_ALLOWED +RUNTIME_AVAILABLE=0 +RUNTIME_LISTEN_PORT='-' + +is_wireguard_key() { [[ $1 =~ ^[A-Za-z0-9+/]{43}=$ ]]; } + +ipv4_to_int() { + local -a octets=() octet + IFS=. read -r -a octets <<<"${1%/*}" + printf '%u\n' "$(( 10#${octets[0]} * 16777216 + 10#${octets[1]} * 65536 + 10#${octets[2]} * 256 + 10#${octets[3]} ))" +} + +cidr_network_v4() { + local address=${1%/*} prefix=${1#*/} mask + mask=$(( prefix == 0 ? 0 : (0xffffffff << (32 - prefix)) & 0xffffffff )) + printf '%u %u\n' "$(( $(ipv4_to_int "$address") & mask ))" "$prefix" +} + +ipv6_groups() { + local value=$1 left right part missing index + local -a left_values=() right_values=() groups=() + if [[ $value == *'::'* ]]; then + left=${value%%::*}; right=${value##*::} + [[ -n $left ]] && IFS=: read -r -a left_values <<<"$left" + [[ -n $right ]] && IFS=: read -r -a right_values <<<"$right" + missing=$(( 8 - ${#left_values[@]} - ${#right_values[@]} )); (( missing >= 1 )) || return 1 + groups=("${left_values[@]}"); for ((index = 0; index < missing; index += 1)); do groups+=(0); done; groups+=("${right_values[@]}") + else + IFS=: read -r -a groups <<<"$value" + fi + (( ${#groups[@]} == 8 )) || return 1 + for part in "${groups[@]}"; do printf '%04x\n' "$((16#$part))"; done +} + +ipv6_prefix_overlap() { + local left=$1 right=$2 left_prefix=${1#*/} right_prefix=${2#*/} prefix bit group_index left_group right_group mask + local -a left_groups=() right_groups=() + prefix=$left_prefix; (( right_prefix < prefix )) && prefix=$right_prefix + mapfile -t left_groups < <(ipv6_groups "${left%/*}") + mapfile -t right_groups < <(ipv6_groups "${right%/*}") + ((${#left_groups[@]} == 8 && ${#right_groups[@]} == 8)) || return 1 + for ((bit = 0; bit < prefix; bit += 1)); do + group_index=$((bit / 16)); mask=$((0x8000 >> (bit % 16))) + left_group=$((16#${left_groups[$group_index]})); right_group=$((16#${right_groups[$group_index]})) + (( (left_group & mask) == (right_group & mask) )) || return 1 + done +} + +validate_cidr() { + local cidr=$1 address prefix + [[ $cidr == */* && $cidr != *[[:space:]]* ]] || return 1 + address=${cidr%/*}; prefix=${cidr#*/} + is_uint "$prefix" || return 1 + if validate_ipv4 "$address"; then (( 10#$prefix <= 32 )); return; fi + validate_ipv6 "$address" && (( 10#$prefix <= 128 )) +} + +cidr_family() { validate_ipv4 "${1%/*}" && printf '4' || printf '6'; } + +cidrs_overlap() { + local left=$1 right=$2 left_family right_family left_network left_prefix right_network right_prefix + left_family=$(cidr_family "$left"); right_family=$(cidr_family "$right"); [[ $left_family == "$right_family" ]] || return 1 + if [[ $left_family == 4 ]]; then + read -r left_network left_prefix <<<"$(cidr_network_v4 "$left")" + read -r right_network right_prefix <<<"$(cidr_network_v4 "$right")" + local common=$left_prefix; (( right_prefix < common )) && common=$right_prefix + local mask=$(( common == 0 ? 0 : (0xffffffff << (32 - common)) & 0xffffffff )) + (( (left_network & mask) == (right_network & mask) )) + return + fi + ipv6_prefix_overlap "$left" "$right" +} + +normalize_allowed_ips() { + local raw=$1 item existing index + local -a values=() + local -A seen=() + IFS=, read -r -a values <<<"$raw" + ((${#values[@]} > 0)) || return 1 + for index in "${!values[@]}"; do + values[$index]=$(trim_space "${values[$index]}") + done + for item in "${values[@]}"; do + [[ -n "$item" ]] && validate_cidr "$item" || return 1 + [[ -z ${seen[$item]+x} ]] || return 1 + seen["$item"]=1 + done + for index in "${!values[@]}"; do + for existing in "${values[@]:index+1}"; do cidrs_overlap "${values[$index]}" "$existing" && return 1; done + done + printf '%s\n' "${values[@]}" | LC_ALL=C sort | paste -sd, - +} + +allowed_sets_overlap() { + local left=$1 right=$2 left_item right_item + local -a left_values=() right_values=() + IFS=, read -r -a left_values <<<"$left"; IFS=, read -r -a right_values <<<"$right" + for left_item in "${left_values[@]}"; do for right_item in "${right_values[@]}"; do cidrs_overlap "$left_item" "$right_item" && return 0; done; done + return 1 +} + +extract_client_addresses() { + local allowed=$1 item address prefix + local -a values=() v4=() v6=() + IFS=, read -r -a values <<<"$allowed" + for item in "${values[@]}"; do + address=${item%/*}; prefix=${item#*/} + if [[ $address == *:* ]]; then v6+=("$item"); elif [[ $prefix == 32 ]]; then v4+=("$item"); fi + done + printf '%s|%s\n' "$(IFS=,; printf '%s' "${v4[*]}")" "$(IFS=,; printf '%s' "${v6[*]}")" +} + +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 $?; } + local normalized existing_key + normalized=$(normalize_allowed_ips "$allowed") || { 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 $?; } + done + DISK_ALLOWED["$key"]=$normalized + local addresses; addresses=$(extract_client_addresses "${DISK_ALLOWED[$key]}") + DISK_IPV4["$key"]=${addresses%%|*}; DISK_IPV6["$key"]=${addresses#*|} + DISK_ENDPOINT["$key"]=${peer_endpoint:-'-'} + DISK_MARKER["$key"]=${peer_marker:-} + DISK_LEGACY["$key"]=${peer_legacy:-} + peer_key=''; peer_allowed=''; peer_endpoint='-'; peer_marker=''; peer_legacy='' +} + +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=() + [[ -r "$file" && ! -L "$file" ]] || { die "$EXIT_VALIDATION" 'Конфигурация WireGuard недоступна или является ссылкой'; return $?; } + while IFS= read -r line || [[ -n "$line" ]]; do + ((line_number += 1)); is_control_free "$line" || { die "$EXIT_VALIDATION" "Управляющий символ в конфигурации WireGuard, строка ${line_number}"; return $?; } + trimmed=$(trim_space "$line"); [[ -z "$trimmed" ]] && continue + if [[ ${trimmed:0:1} == '#' ]]; then + if [[ $trimmed =~ ^#[[:space:]]*${MANAGED_MARKER_PREFIX}[[:space:]]*([A-Za-z0-9._-]+)$ ]]; then + if [[ $section == Peer && ${#peer_fields[@]} -eq 0 ]]; then peer_marker=${BASH_REMATCH[1]}; else pending_marker=${BASH_REMATCH[1]}; fi + fi + if [[ $trimmed =~ ^###[[:space:]]Client[[:space:]]([A-Za-z0-9._-]+)$ ]]; then + if [[ $section == Peer && ${#peer_fields[@]} -eq 0 ]]; then peer_legacy=${BASH_REMATCH[1]}; else pending_legacy=${BASH_REMATCH[1]}; fi + fi + continue + fi + if [[ $trimmed == '[Interface]' ]]; then + [[ -z $section ]] || { die "$EXIT_VALIDATION" 'Повторная секция Interface'; return $?; } + pending_marker=''; pending_legacy=''; section=Interface; continue + elif [[ $trimmed == '[Peer]' ]]; then + [[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" 'Peer до секции Interface'; return $?; } + [[ $section != Peer ]] || { commit_disk_peer "$peer_key" "$peer_allowed" || return $?; } + section=Peer; peer_key=''; peer_allowed=''; peer_endpoint='-'; peer_marker=$pending_marker; peer_legacy=$pending_legacy; pending_marker=''; pending_legacy=''; peer_fields=(); continue + elif [[ $trimmed == \[*\] ]]; then + die "$EXIT_VALIDATION" "Неизвестная секция WireGuard, строка ${line_number}"; return $? + fi + [[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" "Параметр вне секции, строка ${line_number}"; return $?; } + [[ $trimmed =~ ^([A-Za-z][A-Za-z0-9]*)[[:space:]]*=[[:space:]]*(.*)$ ]] || { die "$EXIT_VALIDATION" "Некорректный параметр WireGuard, строка ${line_number}"; return $?; } + key=${BASH_REMATCH[1]}; value=$(trim_space "${BASH_REMATCH[2]}"); [[ -n "$value" ]] || { die "$EXIT_VALIDATION" 'Пустой параметр WireGuard'; return $?; } + [[ $section != Interface || -z $pending_marker$pending_legacy ]] || { pending_marker=''; pending_legacy=''; } + if [[ $section == Interface ]]; then + case "$key" in + PrivateKey|ListenPort|MTU|Table|SaveConfig|FwMark) ;; + Address|DNS|PostUp|PostDown|PreUp|PreDown) continue ;; + *) die "$EXIT_VALIDATION" "Неизвестный параметр Interface: ${key}"; return $? ;; + esac + [[ -z ${interface_fields[$key]+x} ]] || { die "$EXIT_VALIDATION" "Дублирующийся параметр Interface: ${key}"; return $?; } + interface_fields["$key"]=1 + if [[ $key == PrivateKey ]]; then is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный ключ Interface'; return $?; }; fi + if [[ $key == ListenPort ]]; then is_uint "$value" && (( 10#$value <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный ListenPort'; return $?; }; fi + if [[ $key == FwMark ]]; then is_fwmark "$value" || { die "$EXIT_VALIDATION" 'Некорректный FwMark'; return $?; }; fi + continue + fi + case "$key" in + PublicKey) [[ -z ${peer_fields[PublicKey]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в peer'; return $?; }; is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в peer'; return $?; }; peer_key=$value; peer_fields[PublicKey]=1 ;; + PresharedKey) [[ -z ${peer_fields[PresharedKey]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PresharedKey в peer'; return $?; }; is_wireguard_key "$value" || { die "$EXIT_VALIDATION" 'Некорректный PresharedKey'; return $?; }; peer_fields[PresharedKey]=1 ;; + AllowedIPs) [[ -z ${peer_fields[AllowedIPs]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся AllowedIPs в peer'; return $?; }; peer_allowed=$value; peer_fields[AllowedIPs]=1 ;; + Endpoint) [[ -z ${peer_fields[Endpoint]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся Endpoint в peer'; return $?; }; validate_peer_endpoint_value "$value" || { die "$EXIT_VALIDATION" 'Некорректный Endpoint'; return $?; }; peer_endpoint=$value; peer_fields[Endpoint]=1 ;; + PersistentKeepalive) [[ -z ${peer_fields[PersistentKeepalive]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PersistentKeepalive'; return $?; }; is_uint "$value" && (( 10#$value <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный PersistentKeepalive'; return $?; }; peer_fields[PersistentKeepalive]=1 ;; + *) die "$EXIT_VALIDATION" "Неизвестный параметр Peer: ${key}"; return $? ;; + esac + done <"$file" + [[ $section == Interface || $section == Peer ]] || { die "$EXIT_VALIDATION" 'Отсутствует секция Interface'; return $?; } + [[ $section != Peer ]] || commit_disk_peer "$peer_key" "$peer_allowed" +} + +parse_metadata_file() { + local file=$1 line key value line_number=0 existing_key existing_name + local name='' public_key='' ipv4='' ipv6='' created='' config='' mode='' managed='' + [[ -f "$file" && ! -L "$file" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + ((line_number += 1)); line=$(trim_space "$line"); [[ -z "$line" ]] && continue + [[ $line =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || { die "$EXIT_VALIDATION" "Некорректные метаданные: ${file}"; return $?; } + key=${BASH_REMATCH[1]}; value=${BASH_REMATCH[2]}; [[ -n "$value" ]] || { die "$EXIT_VALIDATION" 'Пустое поле метаданных'; return $?; } + case "$key" in + NAME) [[ -z $name ]] || { die "$EXIT_VALIDATION" 'Дубликат NAME в метаданных'; return $?; }; name=$value ;; + PUBLIC_KEY) [[ -z $public_key ]] || { die "$EXIT_VALIDATION" 'Дубликат PUBLIC_KEY в метаданных'; return $?; }; public_key=$value ;; + IPV4) [[ -z $ipv4 ]] || { die "$EXIT_VALIDATION" 'Дубликат IPV4 в метаданных'; return $?; }; ipv4=$value ;; + IPV6) [[ -z $ipv6 ]] || { die "$EXIT_VALIDATION" 'Дубликат IPV6 в метаданных'; return $?; }; ipv6=$value ;; + CREATED_AT) [[ -z $created ]] || { die "$EXIT_VALIDATION" 'Дубликат CREATED_AT в метаданных'; return $?; }; created=$value ;; + CONFIG_PATH) [[ -z $config ]] || { die "$EXIT_VALIDATION" 'Дубликат CONFIG_PATH в метаданных'; return $?; }; config=$value ;; + CLIENT_MODE) [[ -z $mode ]] || { die "$EXIT_VALIDATION" 'Дубликат CLIENT_MODE в метаданных'; return $?; }; mode=$value ;; + MANAGED) [[ -z $managed ]] || { die "$EXIT_VALIDATION" 'Дубликат MANAGED в метаданных'; return $?; }; managed=$value ;; + *) die "$EXIT_VALIDATION" "Неизвестное поле метаданных: ${key}"; return $? ;; + esac + done <"$file" + [[ $name =~ ^[A-Za-z0-9._-]+$ && $name != -* && -n $public_key && -n $ipv4 && $created =~ ^[0-9]+$ && $config == /* ]] || { die "$EXIT_VALIDATION" "Некорректные метаданные: ${file}"; return $?; } + [[ $mode == full-tunnel || $mode == split-tunnel ]] || { die "$EXIT_VALIDATION" "Некорректный режим в метаданных: ${file}"; return $?; } + [[ $managed == 0 || $managed == 1 ]] || { die "$EXIT_VALIDATION" "Некорректный MANAGED в метаданных: ${file}"; return $?; } + validate_path "$config" && [[ $config == "${CLIENT_DIR%/}"/* ]] && { [[ ! -e "$config" || ! -L "$config" ]]; } || { die "$EXIT_VALIDATION" "Некорректный путь клиента в метаданных: ${file}"; return $?; } + is_wireguard_key "$public_key" && validate_ipv4 "$ipv4" || { die "$EXIT_VALIDATION" "Некорректная идентичность в метаданных: ${file}"; return $?; } + if [[ -n $ipv6 ]] && ! validate_ipv6 "$ipv6"; then die "$EXIT_VALIDATION" "Некорректный IPV6 в метаданных: ${file}"; return $?; fi + for existing_key in "${!META_NAME[@]}"; do [[ $existing_key == "$public_key" ]] && { die "$EXIT_VALIDATION" 'Дублирующийся PUBLIC_KEY в метаданных'; return $?; }; done + for existing_name in "${META_NAMES[@]}"; do [[ $existing_name == "$name" ]] && { die "$EXIT_VALIDATION" 'Дублирующееся NAME в метаданных'; return $?; }; done + META_NAME["$public_key"]=$name; META_NAMES+=("$name"); META_IPV4["$public_key"]=$ipv4; META_IPV6["$public_key"]=$ipv6; META_CREATED["$public_key"]=$created; META_CONFIG["$public_key"]=$config; META_MODE["$public_key"]=$mode; META_MANAGED["$public_key"]=$managed +} + +parse_metadata() { + local file + META_NAME=(); META_NAMES=(); META_IPV4=(); META_IPV6=(); META_CREATED=(); META_CONFIG=(); META_MODE=(); META_MANAGED=() + [[ -d "$METADATA_DIR" ]] || return 0 + for file in "$METADATA_DIR"/*; do [[ -e "$file" ]] || continue; parse_metadata_file "$file" || return $?; done +} + +parse_runtime_dump() { + local line fields_count line_number=0 key endpoint allowed handshake rx tx keepalive + local output=$1 + RUNTIME_AVAILABLE=1; RUNTIME_LISTEN_PORT='-'; RUNTIME_ENDPOINT=(); RUNTIME_HANDSHAKE=(); RUNTIME_RX=(); RUNTIME_TX=(); RUNTIME_ALLOWED=() + while IFS= read -r line || [[ -n "$line" ]]; do + ((line_number += 1)); IFS=$'\t' read -r -a fields <<<"$line"; fields_count=${#fields[@]} + if (( line_number == 1 )); then + (( fields_count == 4 )) || { die "$EXIT_VALIDATION" 'Некорректная строка интерфейса wg dump'; return $?; } + if [[ ${fields[0]} != '(none)' ]] && ! is_wireguard_key "${fields[0]}"; then die "$EXIT_VALIDATION" 'Некорректный ключ интерфейса wg dump'; return $?; fi + if [[ ${fields[1]} != '(none)' ]] && ! is_wireguard_key "${fields[1]}"; then die "$EXIT_VALIDATION" 'Некорректный ключ интерфейса wg dump'; return $?; fi + is_uint "${fields[2]}" && (( 10#${fields[2]} <= 65535 )) || { die "$EXIT_VALIDATION" 'Некорректный порт в wg dump'; return $?; }; RUNTIME_LISTEN_PORT=${fields[2]}; continue + fi + (( fields_count == 8 )) || { die "$EXIT_VALIDATION" 'Некорректная строка peer в wg dump'; return $?; } + is_wireguard_key "${fields[0]}" && { [[ ${fields[1]} == '(none)' ]] || is_wireguard_key "${fields[1]}"; } || { die "$EXIT_VALIDATION" 'Некорректный ключ в wg dump'; return $?; } + key=${fields[0]}; endpoint=${fields[2]}; allowed=${fields[3]}; handshake=${fields[4]}; rx=${fields[5]}; tx=${fields[6]}; keepalive=${fields[7]} + [[ $endpoint == '(none)' ]] && endpoint='-' + [[ $allowed == '(none)' ]] && allowed='-' + is_wireguard_key "$key" || { die "$EXIT_VALIDATION" 'Некорректный PublicKey в wg dump'; return $?; } + [[ $endpoint == '-' || -z $endpoint ]] || validate_peer_endpoint_value "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный Endpoint в wg dump'; return $?; } + [[ -z ${RUNTIME_HANDSHAKE[$key]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в wg dump'; return $?; } + if [[ $allowed != '-' ]]; then normalize_allowed_ips "$allowed" >/dev/null || { die "$EXIT_VALIDATION" 'Некорректные AllowedIPs в wg dump'; return $?; }; fi + is_uint "$handshake" && is_uint "$rx" && is_uint "$tx" && is_uint "$keepalive" || { die "$EXIT_VALIDATION" 'Некорректные числа в wg dump'; return $?; } + RUNTIME_ENDPOINT["$key"]=${endpoint:--}; if [[ $allowed == '-' ]]; then RUNTIME_ALLOWED["$key"]='-'; else RUNTIME_ALLOWED["$key"]=$(normalize_allowed_ips "$allowed"); fi; RUNTIME_HANDSHAKE["$key"]=$handshake; RUNTIME_RX["$key"]=$rx; RUNTIME_TX["$key"]=$tx + done <<<"$output" +} + +load_runtime_state() { + local output command_status + RUNTIME_AVAILABLE=0; RUNTIME_LISTEN_PORT='-' + if ! command -v wg >/dev/null 2>&1; then return 0; fi + set +e + output=$(wg show "$WG_INTERFACE" dump 2>/dev/null); command_status=$? + set -e + (( command_status == 0 )) || return 0 + [[ -n "$output" ]] || return 0 + parse_runtime_dump "$output" +} + +current_epoch() { + if [[ ${WG_MANAGER_NOW+x} ]]; then + [[ $WG_MANAGER_NOW =~ ^[0-9]+$ ]] || return "$EXIT_VALIDATION" + printf '%s\n' "$WG_MANAGER_NOW" + else + date +%s + fi +} + +build_client_records() { + local key addresses name origin handshake now state disk_ipv4 disk_ipv6 expected_allowed + local -a keys=() + local -A seen_keys=() + for key in "${!DISK_ALLOWED[@]}" "${!RUNTIME_HANDSHAKE[@]}"; do [[ -n ${seen_keys[$key]+x} ]] || { seen_keys[$key]=1; keys+=("$key"); }; done + CLIENT_KEYS=(); CLIENT_NAMES=(); CLIENT_ADDRESSES=(); CLIENT_ENDPOINTS=(); CLIENT_HANDSHAKES=(); CLIENT_RX=(); CLIENT_TX=(); CLIENT_STATES=(); CLIENT_ORIGINS=() + now=$(current_epoch) || return $? + for key in "${keys[@]}"; do + name='-'; origin=unmanaged; addresses=${DISK_ALLOWED[$key]:-${RUNTIME_ALLOWED[$key]:--}}; handshake=${RUNTIME_HANDSHAKE[$key]:-0} + disk_ipv4=${DISK_IPV4[$key]:-}; disk_ipv4=${disk_ipv4%%/*}; disk_ipv6=${DISK_IPV6[$key]:-}; disk_ipv6=${disk_ipv6%%/*} + if [[ ${META_NAME[$key]+x} && ${META_MANAGED[$key]} == 1 ]]; then + expected_allowed="${META_IPV4[$key]}/32"; [[ -n ${META_IPV6[$key]:-} ]] && expected_allowed+=",${META_IPV6[$key]}/128" + if [[ ${META_IPV4[$key]} == "$disk_ipv4" && ${META_IPV6[$key]:-} == "$disk_ipv6" && ${DISK_ALLOWED[$key]:-} == "$(normalize_allowed_ips "$expected_allowed")" ]]; then name=${META_NAME[$key]}; origin=managed; fi + fi + if [[ $origin != managed && -n ${DISK_MARKER[$key]:-}${DISK_LEGACY[$key]:-} ]]; then origin=legacy; name=${DISK_MARKER[$key]:-${DISK_LEGACY[$key]}}; fi + if [[ $origin == managed ]]; then + if [[ $handshake == 0 ]]; then state=never; elif (( now >= handshake && now - handshake <= RECENT_HANDSHAKE_SECONDS )); then state=active; else state=inactive; fi + else state=unmanaged; fi + CLIENT_KEYS+=("$key"); CLIENT_NAMES+=("$name"); CLIENT_ADDRESSES+=("$addresses"); CLIENT_ENDPOINTS+=("${RUNTIME_ENDPOINT[$key]:-${DISK_ENDPOINT[$key]:--}}"); CLIENT_HANDSHAKES+=("$handshake"); CLIENT_RX+=("${RUNTIME_RX[$key]:-0}"); CLIENT_TX+=("${RUNTIME_TX[$key]:-0}"); CLIENT_STATES+=("$state"); CLIENT_ORIGINS+=("$origin") + done +} + +load_client_model() { + load_config || return $?; parse_wireguard_config "$WG_CONF" || return $?; parse_metadata || return $?; load_runtime_state || return $?; build_client_records +} + +print_status() { + local managed=0 unmanaged=0 active=0 index + for index in "${!CLIENT_KEYS[@]}"; do + if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then ((managed += 1)); else ((unmanaged += 1)); fi + if [[ ${CLIENT_STATES[$index]} == active ]]; then ((active += 1)); fi + done + printf 'Интерфейс: %s\nСостояние runtime: %s\nПорт: %s\nPeers: %d\nУправляемые: %d\nНеуправляемые: %d\nАктивные: %d\n' "$WG_INTERFACE" "$([[ $RUNTIME_AVAILABLE == 1 ]] && printf 'доступен' || printf 'недоступен')" "${RUNTIME_LISTEN_PORT:--}" "${#CLIENT_KEYS[@]}" "$managed" "$unmanaged" "$active" +} + +print_list() { + local index + printf 'NAME\tADDRESSES\tPUBLIC_KEY\tENDPOINT\tHANDSHAKE\tRX\tTX\tSTATE\tORIGIN\n' + for index in "${!CLIENT_KEYS[@]}"; do printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "${CLIENT_NAMES[$index]}" "${CLIENT_ADDRESSES[$index]}" "${CLIENT_KEYS[$index]:0:8}" "${CLIENT_ENDPOINTS[$index]}" "${CLIENT_HANDSHAKES[$index]}" "${CLIENT_RX[$index]}" "${CLIENT_TX[$index]}" "${CLIENT_STATES[$index]}" "${CLIENT_ORIGINS[$index]}"; done | LC_ALL=C sort -t $'\t' -k1,1 -k2,2 -k3,3 +} + require_command() { local command_name=$1 command_path command_path=$(command -v -- "$command_name" 2>/dev/null) || { die "$EXIT_DEPENDENCY" "Не найдена обязательная команда: ${command_name}"; return $?; } @@ -245,7 +603,9 @@ main() { case "$1" in help) print_usage ;; version) print_version ;; - status|list|add|show|config|qr|remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;; + status) load_client_model || return $?; print_status ;; + list) load_client_model || return $?; print_list ;; + add|show|config|qr|remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;; *) local status if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi