From 2e40d5bd1a474077f490df31a7a927645675372a Mon Sep 17 00:00:00 2001 From: sasa Date: Tue, 13 Oct 2026 00:25:19 +0300 Subject: [PATCH] feat(clients): add transactional client creation --- PLANS.md | 42 +- README.md | 13 + tests/fixtures/m4/golden-client-full.txt | 11 + tests/fixtures/m4/golden-client-split.txt | 11 + tests/fixtures/m4/golden-client.meta.txt | 7 + tests/fixtures/m4/golden-server.conf | 16 + tests/mocks/bin/wg | 24 ++ tests/run.sh | 444 +++++++++++++++++++- wg-manager.sh | 470 +++++++++++++++++++++- 9 files changed, 1008 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/m4/golden-client-full.txt create mode 100644 tests/fixtures/m4/golden-client-split.txt create mode 100644 tests/fixtures/m4/golden-client.meta.txt create mode 100644 tests/fixtures/m4/golden-server.conf diff --git a/PLANS.md b/PLANS.md index dbb2c05..fee993a 100644 --- a/PLANS.md +++ b/PLANS.md @@ -258,20 +258,20 @@ These decisions are fixed for the MVP unless the user explicitly changes them: Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`. -| Milestone | Status | Outcome | -|---|---|---| -| M0 | DONE | Test harness and safe project skeleton | -| M1 | DONE | Runtime foundation, CLI shell, and config loading | -| M2 | DONE | WireGuard parser, client model, `status`, and `list` | -| M3 | DONE | Locking, backups, atomic apply, and rollback engine | -| M4 | NOT STARTED | Safe client creation | -| M5 | NOT STARTED | Client detail, config export, and QR display | -| M6 | NOT STARTED | Safe client removal and archival | -| M7 | NOT STARTED | 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 | -| M11 | NOT STARTED | Hardening, documentation, and MVP acceptance | +| Milestone | Status | Outcome | +| --------- | ----------- | ---------------------------------------------------- | +| M0 | DONE | Test harness and safe project skeleton | +| M1 | DONE | Runtime foundation, CLI shell, and config loading | +| M2 | DONE | WireGuard parser, client model, `status`, and `list` | +| M3 | DONE | Locking, backups, atomic apply, and rollback engine | +| M4 | DONE | Safe client creation | +| M5 | NOT STARTED | Client detail, config export, and QR display | +| M6 | NOT STARTED | Safe client removal and archival | +| M7 | NOT STARTED | 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 | +| M11 | NOT STARTED | Hardening, documentation, and MVP acceptance | --- @@ -1607,6 +1607,20 @@ Use this template: - Suggested commit message: `feat(safety): add atomic WireGuard mutation and rollback engine` - Next milestone: M4 +## 2026-10-13 — M4 — DONE + +- Implemented: strict `add NAME` CLI parsing; deterministic IPv4 and optional IPv6 allocation; managed peer, full-tunnel/split-tunnel client configuration, key, PSK, and metadata generation; restrictive artifact publication integrated with the M3 transaction; collision, rollback, interruption, test-hook confinement, and secret-safety coverage using only fixtures and mocks. +- Coordinator: `gpt-5.6-sol` (`high`) +- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`) +- Repair cycles: 11 +- Sol review: fixed configuration-load ordering, missing PSK persistence, pre-commit artifact publication, publish-race and INT/TERM consistency, incomplete rollback and staging cleanup, unsafe test-hook confinement, multiline key-output acceptance, mixed IPv6 collision handling, missing allocation/collision/key-contract matrices, silent expected failures, misleading fixtures, and insufficient secret/mode evidence; every material defect received focused regression coverage. +- Verification: `bash -n wg-manager.sh`, `bash -n tests/run.sh`, `bash -n tests/mocks/bin/wg`, `bash tests/run.sh add-client`, `bash tests/run.sh address-allocation`, `bash tests/run.sh add-collisions`, `bash tests/run.sh add-rollback`, `bash tests/run.sh secret-modes`, `bash tests/run.sh no-secret-logs`, `bash tests/run.sh add-golden-config`, `bash tests/run.sh add-invalid-names`, `bash tests/run.sh transaction`, `bash tests/run.sh rollback`, `bash tests/run.sh locking`, `bash tests/run.sh config`, `bash tests/run.sh`, `git diff --check`, permission, scope, and secret-pattern review. +- Result: all required checks passed; add-client/address-allocation/add-collisions/add-rollback/secret-modes/no-secret-logs/golden/invalid-name suites passed 39/65/104/78/66/41/44/12 assertions, transaction/rollback/locking/config passed 51/65/19/128 assertions, and the full suite passed 355 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; M4 verification is fixture- and mock-based as required. Audit-log creation remains scheduled for M8, so M4 secret checks cover captured stdout, stderr, xtrace, mock logs, generated artifacts, and fixtures. +- Suggested commit message: `feat(clients): add transactional client creation` +- Next milestone: M5 + # 8. Final completion rule The MVP is complete only when: diff --git a/README.md b/README.md index b62a6e4..8e63c1a 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,16 @@ critical rollback error. No M3 mutation command is exposed yet. M3 tests use only isolated overrides and deny-by-default command mocks; they do not inspect or modify host WireGuard state. +## M4 client creation + +`add NAME` creates a client transactionally. The optional forms are +`--mode full-tunnel|split-tunnel` and `--endpoint HOST`; unknown or extra +arguments are rejected. Names are restricted to ASCII letters, digits, `.`, +`_`, and `-` (without a leading `-`). Root is required for production use; +tests enable only an isolated `WG_MANAGER_TEST_MODE` under `TEST_ROOT`. + +Private/public/PSK files, client configuration, and metadata use restrictive +permissions and are removed if the server transaction or rollback fails. +Generated key material is supplied to `wg` through stdin or stdout and is not +placed in command arguments or diagnostic output. + diff --git a/tests/fixtures/m4/golden-client-full.txt b/tests/fixtures/m4/golden-client-full.txt new file mode 100644 index 0000000..a68f0f1 --- /dev/null +++ b/tests/fixtures/m4/golden-client-full.txt @@ -0,0 +1,11 @@ +[Interface] +PrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= +Address = 192.0.2.3/32 +DNS = 192.0.2.53,2001:db8::53 + +[Peer] +PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD= +PresharedKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG= +Endpoint = vpn.example.invalid:51820 +AllowedIPs = 0.0.0.0/0,::/0 +PersistentKeepalive = 25 diff --git a/tests/fixtures/m4/golden-client-split.txt b/tests/fixtures/m4/golden-client-split.txt new file mode 100644 index 0000000..50fded7 --- /dev/null +++ b/tests/fixtures/m4/golden-client-split.txt @@ -0,0 +1,11 @@ +[Interface] +PrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= +Address = 192.0.2.3/32 +DNS = 192.0.2.53,2001:db8::53 + +[Peer] +PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD= +PresharedKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG= +Endpoint = vpn.example.invalid:51820 +AllowedIPs = 192.0.2.0/24 +PersistentKeepalive = 25 diff --git a/tests/fixtures/m4/golden-client.meta.txt b/tests/fixtures/m4/golden-client.meta.txt new file mode 100644 index 0000000..e21fe1e --- /dev/null +++ b/tests/fixtures/m4/golden-client.meta.txt @@ -0,0 +1,7 @@ +NAME=alice +PUBLIC_KEY=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= +IPV4=192.0.2.3 +CREATED_AT=1700000100 +CONFIG_PATH=CLIENT_DIR/alice.conf +CLIENT_MODE=full-tunnel +MANAGED=1 diff --git a/tests/fixtures/m4/golden-server.conf b/tests/fixtures/m4/golden-server.conf new file mode 100644 index 0000000..91d0938 --- /dev/null +++ b/tests/fixtures/m4/golden-server.conf @@ -0,0 +1,16 @@ +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +[Peer] +# wg-manager-client: fixture +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32 +Endpoint = vpn.example.invalid:51820 + +# wg-manager-client: alice +[Peer] +PublicKey = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= +PresharedKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG= +AllowedIPs = 192.0.2.3/32 diff --git a/tests/mocks/bin/wg b/tests/mocks/bin/wg index 20aa5cc..96e7929 100755 --- a/tests/mocks/bin/wg +++ b/tests/mocks/bin/wg @@ -6,6 +6,30 @@ 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 == public-key ]]; then + [[ ${WG_MOCK_FAIL_SERVER_PUBLIC_KEY:-0} != 1 ]] || exit 97 + printf '%s\n' "${WG_MOCK_SERVER_PUBLIC_KEY_OUTPUT:-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=}" + exit 0 +fi +if [[ $# == 1 && $1 == genkey ]]; then + [[ ${WG_MOCK_FAIL_GENKEY:-0} != 1 ]] || exit 97 + printf '%s\n' "${WG_MOCK_PRIVATE_KEY:-EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=}" + exit 0 +fi +if [[ $# == 1 && $1 == pubkey ]]; then + IFS= read -r private_key + [[ -n $private_key && $private_key =~ ^[A-Za-z0-9+/]{43}=$ ]] || exit 97 + if IFS= read -r extra_key; then exit 97; fi + if [[ -n ${WG_MOCK_EXPECTED_PRIVATE_KEY:-} && $private_key != "$WG_MOCK_EXPECTED_PRIVATE_KEY" ]]; then exit 97; fi + [[ ${WG_MOCK_FAIL_PUBKEY:-0} != 1 ]] || exit 97 + printf '%s\n' "${WG_MOCK_PUBLIC_KEY:-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=}" + exit 0 +fi +if [[ $# == 1 && $1 == genpsk ]]; then + [[ ${WG_MOCK_FAIL_GENPSK:-0} != 1 ]] || exit 97 + printf '%s\n' "${WG_MOCK_PSK:-GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=}" + exit 0 +fi if [[ $# == 3 && $1 == show && -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" && $3 == dump && -z ${WG_MOCK_DUMP_SEQUENCE_DIR:-} && -n ${WG_MOCK_DUMP_FILE:-} && -f $WG_MOCK_DUMP_FILE ]]; then if [[ -n ${WG_MOCK_DUMP_FAIL_ON_CALL:-} ]]; then dump_counter=${WG_MOCK_DUMP_FAIL_COUNTER_FILE:-${TEST_ROOT}/dump-counter} diff --git a/tests/run.sh b/tests/run.sh index db6e057..9b97590 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) ;; *) 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) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac test_group=${1-} @@ -104,6 +104,31 @@ run_config_tests() { assert_equal "$TEST_ROOT/archive" "$ARCHIVE_DIR" 'путь архива' assert_equal "$TEST_ROOT/manager.lock" "$LOCK_FILE" 'путь lock-файла' + begin_test 'test hooks require a confined non-symlink root and paths' + assert_equal 1 "$(TEST_ROOT=/ WG_MANAGER_TEST_MODE=1 CONFIG_PATH=/tmp/x transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'TEST_ROOT=/ отклонён' + assert_equal 1 "$(TEST_ROOT=relative WG_MANAGER_TEST_MODE=1 CONFIG_PATH=relative transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'относительный TEST_ROOT отклонён' + ln -s -- "$TEST_ROOT" "${TEST_ROOT}/root-link" + assert_equal 1 "$(TEST_ROOT="${TEST_ROOT}/root-link" WG_MANAGER_TEST_MODE=1 CONFIG_PATH="${TEST_ROOT}/root-link/config" transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'symlink TEST_ROOT отклонён' + assert_equal 1 "$(TEST_ROOT="$TEST_ROOT" WG_MANAGER_TEST_MODE=1 CONFIG_PATH=/tmp/outside transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'CONFIG_PATH вне root отклонён' + assert_equal 1 "$(TEST_ROOT="$TEST_ROOT" WG_MANAGER_TEST_MODE=1 CONFIG_PATH="$CONFIG_PATH" WG_CONF=/tmp/outside transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'WG_CONF вне root отклонён' + for hook_path_name in CLIENT_DIR METADATA_DIR BACKUP_DIR LOCK_FILE; do + assert_equal 1 "$(TEST_ROOT="$TEST_ROOT" WG_MANAGER_TEST_MODE=1 CONFIG_PATH="$CONFIG_PATH" WG_CONF="$WG_CONF"; declare "$hook_path_name=/tmp/outside"; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" "${hook_path_name} вне root отклонён" + done + mkdir -- "${TEST_ROOT}/hook-real"; ln -s -- "${TEST_ROOT}/hook-real" "${TEST_ROOT}/hook-link" + for hook_path_name in WG_CONF CLIENT_DIR METADATA_DIR BACKUP_DIR LOCK_FILE; do + assert_equal 1 "$(TEST_ROOT="$TEST_ROOT" WG_MANAGER_TEST_MODE=1 CONFIG_PATH="$CONFIG_PATH"; declare "$hook_path_name=${TEST_ROOT}/hook-link/value"; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" "${hook_path_name} с symlink-компонентом отклонён" + done + rm -rf -- "${TEST_ROOT}/hook-real" "${TEST_ROOT}/hook-link" + mkfifo "${TEST_ROOT}/hook-ready" "${TEST_ROOT}/hook-release" + ln -s -- "${TEST_ROOT}/hook-ready" "${TEST_ROOT}/hook-ready-link" + printf 'sentinel\n' >"${TEST_ROOT}/hook-pid-sentinel" + ln -s -- "${TEST_ROOT}/hook-pid-sentinel" "${TEST_ROOT}/hook-pid-link" + assert_equal 1 "$(ADD_PUBLISH_READY_FIFO="${TEST_ROOT}/hook-ready-link" ADD_PUBLISH_RELEASE_FIFO="${TEST_ROOT}/hook-release" WG_MANAGER_TEST_MODE=1; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" 'symlink publish FIFO отклонён' + assert_equal 1 "$(ADD_PUBLISH_READY_FIFO="${TEST_ROOT}/hook-ready" ADD_PUBLISH_RELEASE_FIFO="${TEST_ROOT}/hook-release" ADD_PUBLISH_PID_FILE="${TEST_ROOT}/hook-pid-link" WG_MANAGER_TEST_MODE=1; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" 'symlink publish PID отклонён' + assert_equal 1 "$(ADD_PUBLISH_READY_FIFO="${TEST_ROOT}/hook-ready" ADD_PUBLISH_RELEASE_FIFO="${TEST_ROOT}/hook-release" WG_MANAGER_TEST_MODE=0; if transaction_test_hooks_enabled >/dev/null 2>&1; then printf 0; else printf 1; fi)" 'hooks вне test mode отклонены' + rm -f -- "${TEST_ROOT}/hook-ready" "${TEST_ROOT}/hook-release" "${TEST_ROOT}/hook-ready-link" "${TEST_ROOT}/hook-pid-link" "${TEST_ROOT}/hook-pid-sentinel" + rm -f -- "${TEST_ROOT}/root-link" + begin_test 'required keys and parser quoting branches are covered' write_valid_config "${TEST_ROOT}/missing.conf" remove_config_key "${TEST_ROOT}/missing.conf" WG_INTERFACE @@ -154,13 +179,13 @@ run_config_tests() { export WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" export WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock" - begin_test 'known command validates config then reports not implemented' + begin_test 'add without a name reports usage' set +e output=$("${PROJECT_DIR}/wg-manager.sh" add 2>&1) status=$? set -e - assert_equal "$EXIT_OPERATION" "$status" 'статус команды-заглушки' - assert_contains 'пока не реализована' "$output" 'сообщение заглушки' + assert_equal "$EXIT_USAGE" "$status" 'статус add без имени' + assert_contains 'Ожидается имя клиента' "$output" 'сообщение add без имени' begin_test 'invalid configurations are rejected without execution' marker="${TEST_ROOT}/marker" @@ -233,6 +258,7 @@ run_config_tests() { 'nonnumeric_range|START_HOST|abc|Некорректный диапазон' 'out_of_range|START_HOST|0|Некорректный диапазон' 'invalid_ipv6|IPV6_PREFIX|gg::1|Некорректный IPv6' + 'invalid_ipv6_prefix_shape|IPV6_PREFIX|2001:db8:ffff:1|Некорректный IPv6-префикс' 'triple_ipv6_prefix|IPV6_PREFIX|:::|Некорректный IPv6' 'triple_ipv6_dns|CLIENT_DNS|192.0.2.53,:::|Некорректный адрес DNS' 'triple_ipv6_endpoint|SERVER_ENDPOINT|:::|Некорректная конечная точка' @@ -802,6 +828,412 @@ transaction_builder_symlink() { ln -s -- "$WG_CONF" "$1" } +run_m4_tests() { + local config_file="${TEST_ROOT}/m4.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output before + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.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}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MOCK_INTERFACE=wg0 + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_file" + rm -rf -- "${TEST_ROOT}/dump-sequence" + mkdir -p "${TEST_ROOT}/dump-sequence" + cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/2" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"${TEST_ROOT}/dump-sequence/1" + export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_DUMP_SEQUENCE_DIR="${TEST_ROOT}/dump-sequence" + unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK + set_runtime_paths + load_config + rm -rf -- "$METADATA_DIR" "$CLIENT_DIR" + mkdir -p "$METADATA_DIR" "$CLIENT_DIR" + + case $test_group in + add-invalid-names) + begin_test 'invalid names are rejected before key generation' + for name in '' 'bad name' '-option' '../path' 'glob*' $'line\nfeed' 'кириллица'; do + set +e; output=$(add_client "$name" 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" "имя отклонено: ${name:-empty}" + done + assert_equal '' "$(grep -E 'genkey|genpsk|pubkey' "$TEST_MOCK_LOG" || true)" 'генерация не вызвана' + set +e; add_client cli --mode full-tunnel --mode split-tunnel >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'повторный --mode отклонён' + set +e; add_client cli --endpoint >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'отсутствующее значение endpoint отклонено' + set +e; add_client cli --unknown value >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'неизвестный флаг отклонён' + set +e; add_client cli --endpoint vpn.example.invalid:51820 >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'endpoint с портом отклонён' + return + ;; + ''|address-allocation|add-client|add-golden-config|no-secret-logs|secret-modes) + begin_test 'fresh CLI add loads configuration before defaults' + local fresh_add_script="${TEST_ROOT}/fresh-add.sh" fresh_add_status + printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' "cd -- \"$PROJECT_DIR\"" './wg-manager.sh add alice' >"$fresh_add_script" + chmod 700 "$fresh_add_script" + set +e; /bin/bash "$fresh_add_script" >/dev/null 2>&1; fresh_add_status=$?; set -e + assert_equal 0 "$fresh_add_status" 'fresh process add succeeds' + assert_file_exists "${CLIENT_DIR}/alice.conf" 'fresh client config' + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" + rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" + rm -f -- "${TEST_ROOT}/dump-sequence/counter" + begin_test 'successful add creates isolated client artifacts and peer' + : >"$TEST_MOCK_LOG" + export WG_MOCK_EXPECTED_PRIVATE_KEY=EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= + if [[ $test_group == no-secret-logs ]]; then + local trace_file="${TEST_ROOT}/add-xtrace.log" trace_fd + exec {trace_fd}>"$trace_file" + BASH_XTRACEFD=$trace_fd + set -x + set +e; output=$(add_client alice 2>&1); status=$?; set -e + set +x + exec {trace_fd}>&- + else + set +e; output=$(add_client alice 2>&1); status=$?; set -e + fi + unset WG_MOCK_EXPECTED_PRIVATE_KEY + assert_equal 0 "$status" 'add succeeds' + assert_file_exists "${CLIENT_DIR}/alice.conf" 'client config' + assert_file_exists "${CLIENT_DIR}/alice.key" 'client private key' + assert_file_exists "${METADATA_DIR}/alice.meta" 'client metadata' + assert_file_mode 600 "${CLIENT_DIR}/alice.conf" 'config mode' + assert_file_mode 600 "${CLIENT_DIR}/alice.key" 'key mode' + assert_file_mode 600 "${CLIENT_DIR}/alice.pub" 'public key mode' + assert_file_mode 600 "${CLIENT_DIR}/alice.psk" 'psk mode' + assert_file_mode 600 "${METADATA_DIR}/alice.meta" 'metadata mode' + assert_file_mode 700 "$CLIENT_DIR" 'client directory mode' + assert_file_mode 700 "$METADATA_DIR" 'metadata directory mode' + for stage_failure in mkdir chmod meta conf key pub psk; do + export TRANSACTION_ADD_DIRECTORY_FAIL= + unset TRANSACTION_ADD_STAGE_FAIL + if [[ $stage_failure == mkdir || $stage_failure == chmod ]]; then export TRANSACTION_ADD_DIRECTORY_FAIL=$stage_failure; else export TRANSACTION_ADD_STAGE_FAIL=$stage_failure; fi + set +e; add_client "fail-${stage_failure//./-}" >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_OPERATION" "$status" "предтранзакционный отказ ${stage_failure}" + assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -name '.wg-manager.add.*' | wc -l)" "нет stage после ${stage_failure}" + unset TRANSACTION_ADD_DIRECTORY_FAIL TRANSACTION_ADD_STAGE_FAIL + done + assert_contains '192.0.2.3/32' "$(<"${CLIENT_DIR}/alice.conf")" 'allocated address' + assert_contains 'PresharedKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=' "$(<"$fixture")" 'server peer preshared key' + assert_equal '' "$(grep -E 'EEEE|FFFF|GGGG|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'mock log without key material' + local -a add_log_lines=(); mapfile -t add_log_lines <"$TEST_MOCK_LOG" + assert_equal 9 "${#add_log_lines[@]}" 'полный порядок add внешних вызовов' + assert_equal 1 "$(grep -c '^wg show wg0 public-key$' "$TEST_MOCK_LOG" || true)" 'ровно один server public-key' + assert_equal 1 "$(grep -c '^wg genkey$' "$TEST_MOCK_LOG" || true)" 'ровно один genkey' + assert_equal 1 "$(grep -c '^wg pubkey$' "$TEST_MOCK_LOG" || true)" 'ровно один pubkey' + assert_equal 1 "$(grep -c '^wg genpsk$' "$TEST_MOCK_LOG" || true)" 'ровно один genpsk' + assert_equal 1 "$( [[ ${add_log_lines[1]:-} == 'wg show wg0 public-key' ]] && printf 1 || printf 0 )" 'exact server public-key argv' + assert_equal 'wg genkey' "${add_log_lines[2]:-}" 'exact genkey argv' + assert_equal 'wg pubkey' "${add_log_lines[3]:-}" 'exact pubkey argv' + assert_equal 'wg genpsk' "${add_log_lines[4]:-}" 'exact genpsk argv' + if [[ $test_group == no-secret-logs ]]; then + assert_equal '' "$output" 'add stdout/stderr without secrets' + assert_equal '' "$(grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' "$trace_file" || true)" 'xtrace без secret/config values' + fi + if [[ $test_group == address-allocation ]]; then + rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"; rm -f -- "${TEST_ROOT}/dump-sequence/counter" + cat >"${METADATA_DIR}/occupied.meta" <"${TEST_ROOT}/dump-sequence/address.dump" + cp -- "${TEST_ROOT}/dump-sequence/address.dump" "${TEST_ROOT}/dump-sequence/1" + set +e; add_client alice >/dev/null 2>&1; local allocation_status=$?; set -e + assert_equal 0 "$allocation_status" 'metadata-only address allocation succeeds' + assert_contains '192.0.2.4/32' "$(<"${CLIENT_DIR}/alice.conf")" 'metadata address is skipped' + rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/occupied.meta" + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"; rm -f -- "${TEST_ROOT}/dump-sequence/counter" + replace_config_line "$config_file" 'USE_IPV6=' 'USE_IPV6="1"' + sed 's/192\.0\.2\.4\/32/192.0.2.3\/32,2001:db8:ffff::3\/128/' "${TEST_ROOT}/dump-sequence/1" >"${TEST_ROOT}/dump-sequence/ipv6.dump" + cp -- "${TEST_ROOT}/dump-sequence/ipv6.dump" "${TEST_ROOT}/dump-sequence/1" + set +e; add_client alice >/dev/null 2>&1; allocation_status=$?; set -e + assert_equal 0 "$allocation_status" 'ipv6 add succeeds' + assert_contains '2001:db8:ffff::3/128' "$(<"${CLIENT_DIR}/alice.conf")" 'client ipv6 address' + assert_contains '2001:db8:ffff::3' "$(<"${METADATA_DIR}/alice.meta")" 'metadata ipv6 address' + rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/ipv6-occupied.meta" + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" + replace_config_line "$config_file" 'START_HOST=' 'START_HOST="10"' + replace_config_line "$config_file" 'END_HOST=' 'END_HOST="10"' + replace_config_line "$config_file" 'USE_IPV6=' 'USE_IPV6="1"' + sed 's/192\.0\.2\.3\/32/192.0.2.10\/32,2001:db8:ffff::a\/128/' "${TEST_ROOT}/dump-sequence/1" >"${TEST_ROOT}/dump-sequence/boundary.dump" + cp -- "${TEST_ROOT}/dump-sequence/boundary.dump" "${TEST_ROOT}/dump-sequence/1" + load_config; parse_wireguard_config "$fixture"; parse_metadata + assert_equal '192.0.2.10/32' "$(allocate_client_ipv4)" 'boundary host 10 allocation' + assert_equal '2001:db8:ffff::a/128' "$(allocate_client_ipv6 10)" 'hex host identity a' + replace_config_line "$config_file" 'START_HOST=' 'START_HOST="2"' + replace_config_line "$config_file" 'END_HOST=' 'END_HOST="2"' + replace_config_line "$config_file" 'USE_IPV6=' 'USE_IPV6="1"' + rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG"; : >"$TEST_MOCK_LOG" + mixed_before=$(sha256sum "$fixture"); local exhaustion_attrs; exhaustion_attrs=$(stat -c '%a:%u:%g' "$fixture") + set +e; output=$(add_client exhausted 2>&1); mixed_status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$mixed_status" 'IPv4 pool exhaustion rejected' + assert_contains 'диапазон IPv4' "$output" 'IPv4 exhaustion diagnostic' + assert_equal '' "$(printf '%s' "$output" | grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" 'IPv4 exhaustion diagnostic is secret-free' + assert_equal "$mixed_before" "$(sha256sum "$fixture")" 'IPv4 exhaustion preserves source' + assert_equal "$exhaustion_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'IPv4 exhaustion preserves attributes' + assert_equal '' "$(grep -E '^wg syncconf |^wg genkey$' "$TEST_MOCK_LOG" || true)" 'IPv4 exhaustion before mutation' + for exhausted_artifact in "${METADATA_DIR}/exhausted.meta" "${CLIENT_DIR}/exhausted.conf" "${CLIENT_DIR}/exhausted.key" "${CLIENT_DIR}/exhausted.pub" "${CLIENT_DIR}/exhausted.psk"; do + assert_file_absent "$exhausted_artifact" 'IPv4 exhaustion leaves final artifact absent' + done + assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 \( -name '.wg-manager.add.*' -o -name '.wg-manager.key.*' \) | wc -l)" 'IPv4 exhaustion leaves no temps' + replace_config_line "$config_file" 'START_HOST=' 'START_HOST="2"' + replace_config_line "$config_file" 'END_HOST=' 'END_HOST="254"' + replace_config_line "$config_file" 'USE_IPV6=' 'USE_IPV6="1"' + rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/AllowedIPs = 192.0.2.2\/32/AllowedIPs = 192.0.2.2\/32, 2001:db8:ffff::3\/128/' "$fixture" + rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG"; : >"$TEST_MOCK_LOG" + local mixed_before mixed_status + mixed_before=$(sha256sum "$fixture") + set +e; add_client mixed-ipv6 >/dev/null 2>&1; mixed_status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$mixed_status" 'mixed disk IPv6 collision rejected' + assert_equal "$mixed_before" "$(sha256sum "$fixture")" 'mixed IPv6 collision preserves source' + assert_equal '' "$(grep -E '^wg syncconf ' "$TEST_MOCK_LOG" || true)" 'mixed IPv6 collision no syncconf' + assert_equal '' "$(grep -E '^wg genkey$' "$TEST_MOCK_LOG" || true)" 'mixed IPv6 collision before key generation' + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" + cat >"${METADATA_DIR}/ipv6-occupied.meta" <"$TEST_MOCK_LOG" + mixed_before=$(sha256sum "$fixture") + set +e; add_client metadata-ipv6 >/dev/null 2>&1; mixed_status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$mixed_status" 'metadata-only IPv6 collision rejected' + assert_equal "$mixed_before" "$(sha256sum "$fixture")" 'metadata IPv6 collision preserves source' + assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -name '.wg-manager.add.*' | wc -l)" 'no artifacts after IPv6 collision' + rm -f -- "${METADATA_DIR}/ipv6-occupied.meta" + fi + if [[ $test_group == add-golden-config ]]; then + assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m4/golden-server.conf")" "$(<"$fixture")" 'server full golden bytes' + assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m4/golden-client-full.txt")" "$(<"${CLIENT_DIR}/alice.conf")" 'full tunnel golden bytes' + assert_equal "$(sed "s|CLIENT_DIR|$CLIENT_DIR|" "${PROJECT_DIR}/tests/fixtures/m4/golden-client.meta.txt")" "$(<"${METADATA_DIR}/alice.meta")" 'metadata golden bytes' + rm -f -- "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta" + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"; rm -f -- "${TEST_ROOT}/dump-sequence/counter" + set +e; add_client alice --mode split-tunnel >/dev/null 2>&1; local split_status=$?; set -e + assert_equal 0 "$split_status" 'split tunnel add succeeds' + assert_equal "$(<"${PROJECT_DIR}/tests/fixtures/m4/golden-client-split.txt")" "$(<"${CLIENT_DIR}/alice.conf")" 'split tunnel golden bytes' + fi + return + ;; + add-collisions) + begin_test 'name and path collisions are rejected' + mkdir -p "$CLIENT_DIR"; printf 'existing\n' >"${CLIENT_DIR}/bob.conf"; chmod 600 "${CLIENT_DIR}/bob.conf" + set +e; output=$(add_client bob 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'existing client path collision' + assert_contains 'Добавление клиента' "$output" 'existing path diagnostic' + assert_equal '' "$(printf '%s' "$output" | grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" 'existing path diagnostic is secret-free' + set +e; add_client fixture >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'explicit marker name collision' + cat >"${METADATA_DIR}/managed.meta" </dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'metadata name collision' + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/# wg-manager-client: fixture/### Client legacy/' "$fixture" + set +e; add_client legacy >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'legacy marker name collision' + for artifact_case in meta conf key pub psk; do + rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" + local artifact_path + case $artifact_case in + meta) artifact_path="${METADATA_DIR}/collision.meta" ;; + *) artifact_path="${CLIENT_DIR}/collision.${artifact_case}" ;; + esac + printf 'administrator sentinel\n' >"$artifact_path"; chmod 600 "$artifact_path" + set +e; output=$(add_client collision 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" "regular ${artifact_case} collision" + assert_equal 1 "$([[ -n $output ]] && printf 1 || printf 0)" "regular ${artifact_case} diagnostic" + assert_equal 'administrator sentinel' "$(<"$artifact_path")" "regular ${artifact_case} preserved" + rm -f -- "$artifact_path" + printf 'outside sentinel\n' >"${TEST_ROOT}/outside-${artifact_case}" + ln -s -- "${TEST_ROOT}/outside-${artifact_case}" "$artifact_path" + set +e; output=$(add_client collision 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" "symlink ${artifact_case} collision" + assert_equal 1 "$([[ -n $output ]] && printf 1 || printf 0)" "symlink ${artifact_case} diagnostic" + assert_equal "${TEST_ROOT}/outside-${artifact_case}" "$(readlink "$artifact_path")" "symlink ${artifact_case} preserved" + rm -f -- "$artifact_path" "${TEST_ROOT}/outside-${artifact_case}" + done + ln -s -- "${TEST_ROOT}/outside" "${CLIENT_DIR}/symlink.conf" + set +e; add_client symlink >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'final artifact symlink collision' + export WG_MOCK_PUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= + set +e; add_client keycollision >/dev/null 2>&1; status=$?; set -e + unset WG_MOCK_PUBLIC_KEY + assert_equal "$EXIT_VALIDATION" "$status" 'generated public key collision' + cat >"${METADATA_DIR}/key-only.meta" </dev/null 2>&1; status=$?; set -e + unset WG_MOCK_PUBLIC_KEY + assert_equal "$EXIT_VALIDATION" "$status" 'metadata-only public key collision' + rm -f -- "${METADATA_DIR}/key-only.meta" + for key_case in private-invalid public-invalid psk-invalid; do + unset WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK + case $key_case in + private-invalid) export WG_MOCK_PRIVATE_KEY='not-a-key' ;; + public-invalid) export WG_MOCK_PUBLIC_KEY='not-a-key' ;; + psk-invalid) export WG_MOCK_PSK=$'not-a-key\nextra' ;; + esac + set +e; output=$(add_client "key-${key_case}" 2>&1); status=$?; set -e + assert_equal "$EXIT_OPERATION" "$status" "${key_case} rejected" + assert_contains 'Добавление клиента' "$output" "${key_case} diagnostic" + assert_equal '' "$(printf '%s' "$output" | grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" "${key_case} diagnostic is secret-free" + unset WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK + done + for key_case in server-public-fail server-public-multiline genkey-fail pubkey-fail genpsk-fail private-multiline public-multiline psk-multiline; do + unset WG_MOCK_FAIL_SERVER_PUBLIC_KEY WG_MOCK_SERVER_PUBLIC_KEY_OUTPUT WG_MOCK_FAIL_GENKEY WG_MOCK_FAIL_PUBKEY WG_MOCK_FAIL_GENPSK WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK + case $key_case in + server-public-fail) export WG_MOCK_FAIL_SERVER_PUBLIC_KEY=1 ;; + server-public-multiline) export WG_MOCK_SERVER_PUBLIC_KEY_OUTPUT=$'DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=\n' ;; + genkey-fail) export WG_MOCK_FAIL_GENKEY=1 ;; + pubkey-fail) export WG_MOCK_FAIL_PUBKEY=1 ;; + genpsk-fail) export WG_MOCK_FAIL_GENPSK=1 ;; + private-multiline) export WG_MOCK_PRIVATE_KEY=$'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' ;; + public-multiline) export WG_MOCK_PUBLIC_KEY=$'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' ;; + psk-multiline) export WG_MOCK_PSK=$'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' ;; + esac + rm -f -- "${TEST_ROOT}/dump-sequence/counter" + local key_before; key_before=$(sha256sum "$fixture") + set +e; output=$(add_client "key-${key_case}" 2>&1); status=$?; set -e + assert_equal "$EXIT_OPERATION" "$status" "${key_case} rejected" + assert_contains 'Добавление клиента' "$output" "${key_case} diagnostic" + assert_equal '' "$(printf '%s' "$output" | grep -E 'DDDD|EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" "${key_case} diagnostic is secret-free" + assert_equal "$key_before" "$(sha256sum "$fixture")" "${key_case} source preserved" + assert_equal '' "$(grep -E '^wg syncconf ' "$TEST_MOCK_LOG" || true)" "${key_case} no syncconf" + assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -name '.wg-manager.add.*' | wc -l)" "no artifacts after ${key_case}" + assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -name '.wg-manager.key.*' | wc -l)" "no key temp after ${key_case}" + unset WG_MOCK_FAIL_SERVER_PUBLIC_KEY WG_MOCK_SERVER_PUBLIC_KEY_OUTPUT WG_MOCK_FAIL_GENKEY WG_MOCK_FAIL_PUBKEY WG_MOCK_FAIL_GENPSK WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK + done + return + ;; + add-rollback) + begin_test 'failed apply removes newly generated artifacts' + export WG_SYNC_FAIL=1 + set +e; add_client alice >/dev/null 2>&1; status=$?; set -e + unset WG_SYNC_FAIL + assert_equal "$EXIT_ROLLBACK" "$status" 'rollback failure is distinct' + assert_file_absent "${CLIENT_DIR}/alice.conf" 'config cleaned after failure' + assert_file_absent "${METADATA_DIR}/alice.meta" 'metadata cleaned after failure' + begin_test 'publish failure rolls back server and runtime' + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"; rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR"; rm -f -- "${TEST_ROOT}/dump-sequence/counter" + before=$(sha256sum "$fixture") + export ADD_PUBLISH_FAIL=1 + set +e; output=$(add_client alice 2>&1); status=$?; set -e + unset ADD_PUBLISH_FAIL + assert_equal "$EXIT_OPERATION" "$status" 'ошибка публикации откатывается' + assert_contains 'Добавление клиента' "$output" 'ошибка публикации сообщает причину' + assert_equal '' "$(printf '%s' "$output" | grep -E 'EEEE|FFFF|GGGG|PrivateKey|PresharedKey' || true)" 'ошибка публикации без секретов' + assert_equal "$before" "$(sha256sum "$fixture")" 'server restored after publish failure' + assert_file_absent "${CLIENT_DIR}/alice.conf" 'publish failure client config absent' + assert_file_absent "${METADATA_DIR}/alice.meta" 'publish failure metadata absent' + begin_test 'publish race preserves administrator target' + rm -f -- "${TEST_ROOT}/dump-sequence/counter"; export ADD_PUBLISH_RACE=1 + set +e; add_client alice >/dev/null 2>&1; status=$?; set -e + unset ADD_PUBLISH_RACE + assert_equal "$EXIT_OPERATION" "$status" 'publish race status' + assert_equal 'administrator collision' "$(<"${METADATA_DIR}/alice.meta")" 'administrator target preserved' + rm -f -- "${METADATA_DIR}/alice.meta" + begin_test 'publish rollback failure is critical' + rm -f -- "${TEST_ROOT}/dump-sequence/counter"; export ADD_PUBLISH_FAIL=1 TRANSACTION_ROLLBACK_REPLACE_FAIL=1 + set +e; output=$(add_client alice 2>&1); status=$?; set -e + unset ADD_PUBLISH_FAIL TRANSACTION_ROLLBACK_REPLACE_FAIL + assert_equal "$EXIT_ROLLBACK" "$status" 'publish rollback failure status' + assert_contains 'КРИТИЧНО' "$output" 'publish rollback critical' + local publish_env_path; publish_env_path=$(command -v env) + for publish_case in 'INT:0:operation' 'TERM:2:operation' 'INT:4:operation' 'TERM:4:operation' 'INT:0:rollback'; do + IFS=: read -r publish_signal publish_index publish_outcome <<<"$publish_case" + begin_test "${publish_signal} during publish index ${publish_index} is rolled back" + cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture" + rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"; mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" + rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG" + : >"$TEST_MOCK_LOG" + local ready_fifo="${TEST_ROOT}/publish-${publish_signal}-${publish_index}-${publish_outcome}.ready" + local release_fifo="${TEST_ROOT}/publish-${publish_signal}-${publish_index}-${publish_outcome}.release" + local pid_file="${TEST_ROOT}/publish-${publish_signal}-${publish_index}-${publish_outcome}.pid" + local publish_script="${TEST_ROOT}/publish-${publish_signal}-${publish_index}-${publish_outcome}.sh" + local cli_pid transaction_pid publish_status publish_before publish_attrs ready release_fd ready_fd tail_status + rm -f -- "$ready_fifo" "$release_fifo" "$pid_file" + mkfifo "$ready_fifo" "$release_fifo" + exec {ready_fd}<>"$ready_fifo" + exec {release_fd}<>"$release_fifo" + publish_before=$(sha256sum "$fixture"); publish_attrs=$(stat -c '%a:%u:%g' "$fixture") + printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'source "$1"' 'set +e' 'add_client alice' 'status=$?' 'exit "$status"' >"$publish_script" + chmod 700 "$publish_script" + export ADD_PUBLISH_READY_FIFO=$ready_fifo ADD_PUBLISH_RELEASE_FIFO=$release_fifo ADD_PUBLISH_PID_FILE=$pid_file ADD_PUBLISH_READY_INDEX=$publish_index + if [[ $publish_outcome == rollback ]]; then export TRANSACTION_ROLLBACK_REPLACE_FAIL=1; else unset TRANSACTION_ROLLBACK_REPLACE_FAIL; fi + "$publish_env_path" --default-signal=INT --default-signal=TERM WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="$BACKUP_DIR" WG_MANAGER_METADATA_DIR="$METADATA_DIR" WG_MANAGER_LOCK_FILE="$LOCK_FILE" ADD_PUBLISH_READY_FIFO="$ready_fifo" ADD_PUBLISH_RELEASE_FIFO="$release_fifo" ADD_PUBLISH_PID_FILE="$pid_file" ADD_PUBLISH_READY_INDEX="$publish_index" TRANSACTION_ROLLBACK_REPLACE_FAIL="${TRANSACTION_ROLLBACK_REPLACE_FAIL:-0}" /bin/bash "$publish_script" "$PROJECT_DIR/wg-manager.sh" >"${publish_script}.out" 2>&1 & cli_pid=$! + if IFS= read -r -t 10 -u "$ready_fd" ready && [[ $ready == ready ]] && IFS= read -r -t 10 publish_pid <"$pid_file" && [[ $publish_pid =~ ^[0-9]+$ ]]; then + transaction_pid=$publish_pid + kill -s "$publish_signal" -- "$transaction_pid" + printf 'release\n' >&"$release_fd" + set +e + timeout 10 tail --pid="$cli_pid" -f /dev/null >/dev/null 2>&1; tail_status=$? + wait "$cli_pid"; publish_status=$? + set -e + if [[ $publish_outcome == rollback ]]; then + assert_equal "$EXIT_ROLLBACK" "$publish_status" "${publish_signal} publish rollback failure status" + assert_contains 'КРИТИЧНО' "$(<"${publish_script}.out")" 'publish rollback failure diagnostic' + else + assert_equal "$EXIT_OPERATION" "$publish_status" "${publish_signal} publish status" + assert_contains "сигналом ${publish_signal}" "$(<"${publish_script}.out")" "${publish_signal} diagnostic" + fi + assert_equal 0 "$tail_status" "${publish_signal} child completed bounded" + if [[ $publish_outcome == operation ]]; then + assert_equal "$publish_before" "$(sha256sum "$fixture")" "${publish_signal} source preserved" + assert_equal "$publish_attrs" "$(stat -c '%a:%u:%g' "$fixture")" "${publish_signal} attributes preserved" + assert_equal 2 "$(grep -c '^wg syncconf wg0 ' "$TEST_MOCK_LOG" || true)" "${publish_signal} syncconf rollback pair" + assert_equal 2 "$(grep -c '^wg show wg0 dump$' "$TEST_MOCK_LOG" || true)" "${publish_signal} dump rollback pair" + fi + for publish_artifact in "${CLIENT_DIR}/alice.conf" "${CLIENT_DIR}/alice.key" "${CLIENT_DIR}/alice.pub" "${CLIENT_DIR}/alice.psk" "${METADATA_DIR}/alice.meta"; do assert_file_absent "$publish_artifact" "${publish_signal} owned artifact cleanup"; done + assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.add.*' | wc -l)" "${publish_signal} add stages cleanup" + unset ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX TRANSACTION_ROLLBACK_REPLACE_FAIL + rm -f -- "${TEST_ROOT}/dump-sequence/counter" "$TEST_MOCK_LOG" + if [[ $publish_outcome == operation ]]; then + set +e; add_client after_signal >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" "${publish_signal} lock reusable" + rm -f -- "${CLIENT_DIR}/after_signal.conf" "${CLIENT_DIR}/after_signal.key" "${CLIENT_DIR}/after_signal.pub" "${CLIENT_DIR}/after_signal.psk" "${METADATA_DIR}/after_signal.meta" + fi + else + kill -TERM "$cli_pid" 2>/dev/null || : + printf 'release\n' >&"$release_fd" || : + set +e; wait "$cli_pid"; set -e + record_failure "${publish_signal} publish synchronization failed" + fi + exec {ready_fd}>&- + exec {release_fd}>&- + rm -f -- "$ready_fifo" "$release_fifo" "$pid_file" "$publish_script" "${publish_script}.out" + done + return + ;; + esac +} + run_m3_tests() { local config_file="${TEST_ROOT}/m3.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output before after backup_count manifest_content parent_exit parent_int parent_term cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture" @@ -1609,6 +2041,9 @@ elif [[ $test_group == parser || $test_group == status || $test_group == list || run_m2_focused "$test_group" elif [[ $test_group == transaction || $test_group == rollback || $test_group == locking || $test_group == backups || $test_group == mutation-preserves-source || $test_group == secret-modes ]]; then run_m3_tests + [[ $test_group == secret-modes ]] && run_m4_tests +elif [[ $test_group == add-client || $test_group == address-allocation || $test_group == add-collisions || $test_group == add-rollback || $test_group == no-secret-logs || $test_group == add-golden-config || $test_group == add-invalid-names ]]; then + run_m4_tests else run_config_tests run_gum_tests @@ -1616,6 +2051,7 @@ else run_debug_tests run_m2_tests run_m3_tests + run_m4_tests fi finish_tests diff --git a/wg-manager.sh b/wg-manager.sh index aafbac6..a5c94c2 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|show|config|qr|remove|diagnose|logs|backups|restore|tui} + wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show|config|qr|remove|diagnose|logs|backups|restore|tui} Команды управления WireGuard будут добавлены в следующих версиях. USAGE @@ -76,6 +76,11 @@ debug() { return 0 } +add_failure() { + local status=$1 message=$2 + die "$status" "Добавление клиента: ${message}" +} + trim_space() { local value=$1 value="${value#${value%%[!$' \t']*}}" @@ -245,7 +250,7 @@ validate_config_values() { is_uint "$START_HOST" && is_uint "$END_HOST" && (( 10#$START_HOST >= 1 && 10#$START_HOST <= 254 && 10#$END_HOST >= 1 && 10#$END_HOST <= 254 && 10#$START_HOST <= 10#$END_HOST )) || { die "$EXIT_VALIDATION" 'Некорректный диапазон адресов клиентов'; return $?; } server_host=${SERVER_VPN_IP##*.}; (( 10#$START_HOST > 10#$server_host || 10#$server_host > 10#$END_HOST )) || { die "$EXIT_VALIDATION" 'Диапазон клиентов содержит адрес сервера'; return $?; } [[ $USE_IPV6 == 0 || $USE_IPV6 == 1 ]] || { die "$EXIT_VALIDATION" 'USE_IPV6 должен быть 0 или 1'; return $?; } - validate_ipv6 "$IPV6_PREFIX" || { die "$EXIT_VALIDATION" 'Некорректный IPv6-префикс'; return $?; } + [[ $IPV6_PREFIX == *'::' && $IPV6_PREFIX != *'::'*'::'* ]] && validate_ipv6 "$IPV6_PREFIX" || { die "$EXIT_VALIDATION" 'Некорректный IPv6-префикс'; return $?; } [[ $DEFAULT_CLIENT_MODE == full-tunnel || $DEFAULT_CLIENT_MODE == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; } is_uint "$BACKUP_RETENTION" && (( 10#$BACKUP_RETENTION >= 0 && 10#$BACKUP_RETENTION <= 1000 )) || { die "$EXIT_VALIDATION" 'Некорректное число резервных копий'; return $?; } is_uint "$RECENT_HANDSHAKE_SECONDS" && (( 10#$RECENT_HANDSHAKE_SECONDS <= 604800 )) || { die "$EXIT_VALIDATION" 'Некорректный срок handshake'; return $?; } @@ -563,10 +568,27 @@ load_client_model() { # path. It must write the complete proposed config and return nonzero to abort. TRANSACTION_TEMP_PATHS=() TRANSACTION_BACKUP_PATH='' +TRANSACTION_FINALIZER='' +TRANSACTION_OWNED_PATHS=() +TRANSACTION_PENDING_SIGNAL='' +TRANSACTION_ROLLBACK_ACTIVE=0 +TRANSACTION_ROLLBACK_INTERRUPTED=0 +TRANSACTION_ROLLBACK_SIGNAL='' transaction_test_hooks_enabled() { - [[ ${WG_MANAGER_TEST_MODE:-0} == 1 && -n ${TEST_ROOT:-} && $TEST_ROOT == /* ]] || return 1 - is_control_free "$TEST_ROOT" && path_components_safe "$TEST_ROOT" + local path config_path + [[ ${WG_MANAGER_TEST_MODE:-0} == 1 && -n ${TEST_ROOT:-} && $TEST_ROOT != / && $TEST_ROOT == /* && -d $TEST_ROOT && ! -L $TEST_ROOT ]] || return 1 + is_control_free "$TEST_ROOT" && path_components_safe "$TEST_ROOT" || return 1 + config_path=${CONFIG_PATH:-${WG_MANAGER_CONFIG:-}} + [[ -n $config_path && $config_path == "${TEST_ROOT%/}"/* ]] || return 1 + path_components_safe "$config_path" || return 1 + for path in "${WG_CONF:-}" "${CLIENT_DIR:-}" "${METADATA_DIR:-}" "${BACKUP_DIR:-}" "${LOCK_FILE:-}"; do + [[ -n $path && $path == "${TEST_ROOT%/}"/* ]] && path_components_safe "$path" || return 1 + done + for path in "${ADD_PUBLISH_READY_FIFO:-}" "${ADD_PUBLISH_RELEASE_FIFO:-}" "${ADD_PUBLISH_PID_FILE:-}"; do + [[ -z $path ]] && continue + [[ $path == "${TEST_ROOT%/}"/* ]] && is_control_free "$path" && path_components_safe "$path" || return 1 + done } transaction_cleanup() { @@ -578,15 +600,27 @@ transaction_cleanup() { fi fi done + for path in "${TRANSACTION_OWNED_PATHS[@]:-}"; do + if [[ -n "$path" && ( -e "$path" || -L "$path" ) ]]; then + if ! rm -f -- "$path"; then + printf 'Предупреждение: не удалось удалить опубликованный временный файл\n' >&2 + fi + fi + done TRANSACTION_TEMP_PATHS=() + TRANSACTION_OWNED_PATHS=() return "$incoming_status" } transaction_signal() { local signal=$1 - transaction_cleanup - printf 'Ошибка: операция прервана сигналом %s\n' "$signal" >&2 - exit "$EXIT_OPERATION" + if (( TRANSACTION_ROLLBACK_ACTIVE == 1 )); then + TRANSACTION_ROLLBACK_INTERRUPTED=1 + TRANSACTION_ROLLBACK_SIGNAL=$signal + else + TRANSACTION_PENDING_SIGNAL=$signal + fi + return 130 } TRANSACTION_CREATED_TEMP='' @@ -810,10 +844,15 @@ run_transaction_core() { local transaction_path shift 3 TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH='' + TRANSACTION_OWNED_PATHS=(); TRANSACTION_PENDING_SIGNAL=''; TRANSACTION_ROLLBACK_ACTIVE=0; TRANSACTION_ROLLBACK_INTERRUPTED=0; TRANSACTION_ROLLBACK_SIGNAL='' [[ $operation =~ ^[A-Za-z][A-Za-z0-9._:-]{0,63}$ && $target =~ ^[A-Za-z][A-Za-z0-9._:/-]{0,127}$ ]] || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; } is_control_free "$operation" && is_control_free "$target" || { die "$EXIT_VALIDATION" 'Некорректная операция или цель транзакции'; return $?; } [[ $builder =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { die "$EXIT_VALIDATION" 'Некорректный builder транзакции'; return $?; } declare -F "$builder" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Builder транзакции не является функцией'; return $?; } + if [[ -n ${TRANSACTION_FINALIZER:-} ]]; then + [[ $TRANSACTION_FINALIZER =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { die "$EXIT_VALIDATION" 'Некорректный finalizer транзакции'; return $?; } + declare -F "$TRANSACTION_FINALIZER" >/dev/null 2>&1 || { die "$EXIT_VALIDATION" 'Finalizer транзакции не является функцией'; return $?; } + fi set_runtime_paths || return $? for transaction_path in "$WG_CONF" "$LOCK_FILE" "$BACKUP_DIR" "$METADATA_DIR"; do path_components_safe "$transaction_path" || { die "$EXIT_VALIDATION" 'Путь транзакции содержит символическую ссылку'; return $?; } @@ -848,6 +887,10 @@ run_transaction_core() { export TRANSACTION_IN_CRITICAL=1 if "$builder" "$stage"; then :; else status=$?; fi unset TRANSACTION_IN_CRITICAL + if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} ]]; then + printf 'Ошибка: операция прервана сигналом %s\n' "$TRANSACTION_PENDING_SIGNAL" >&2 + status=$EXIT_OPERATION + fi if ((status != 0)); then transaction_cleanup; exec {lock_fd}>&-; return "$status"; fi [[ -s "$stage" && -f "$stage" && ! -L "$stage" ]] || { die "$EXIT_VALIDATION" 'Builder не создал безопасный staged config'; status=$?; transaction_cleanup; exec {lock_fd}>&-; return "$status"; } create_transaction_temp strip.new || status=$?; new_stripped=$TRANSACTION_CREATED_TEMP @@ -873,7 +916,26 @@ run_transaction_core() { current_dump=$(wg show "$WG_INTERFACE" dump 2>/dev/null) || status=$EXIT_OPERATION [[ $status == 0 ]] && verify_expected_dump "$WG_CONF" "$current_dump" || status=$EXIT_OPERATION fi + if (( status == 0 )) && [[ -n ${TRANSACTION_FINALIZER:-} ]]; then + if "$TRANSACTION_FINALIZER"; then + trap '' INT TERM + if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} ]]; then + trap 'transaction_signal INT' INT + trap 'transaction_signal TERM' TERM + status=$EXIT_OPERATION + else + TRANSACTION_OWNED_PATHS=() + fi + else + status=$EXIT_OPERATION + fi + fi + if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} ]]; then + printf 'Ошибка: операция прервана сигналом %s\n' "$TRANSACTION_PENDING_SIGNAL" >&2 + status=$EXIT_OPERATION + fi if ((status != 0)) && [[ -n ${backup_path:-} ]]; then + TRANSACTION_ROLLBACK_ACTIVE=1 if ! cp -- "$original_copy" "$stage" || ! chmod 600 -- "$stage" || { [[ ${TRANSACTION_ROLLBACK_REPLACE_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; } || ! mv -- "$stage" "$WG_CONF" || ! restore_file_metadata "$WG_CONF" "$source_mode" "$source_uid" "$source_gid"; then printf 'КРИТИЧНО: откат диска не выполнен\n' >&2; status=$EXIT_ROLLBACK elif ! run_syncconf "$WG_INTERFACE" "$old_stripped"; then @@ -882,8 +944,17 @@ run_transaction_core() { rollback_status=0 prior_dump=$(wg show "$WG_INTERFACE" dump 2>/dev/null) || rollback_status=$EXIT_ROLLBACK if ((rollback_status == 0)) && ! verify_expected_dump "$WG_CONF" "$prior_dump"; then rollback_status=$EXIT_ROLLBACK; fi - if ((rollback_status != 0)); then printf 'КРИТИЧНО: проверка отката не выполнена\n' >&2; status=$EXIT_ROLLBACK; else status=$EXIT_OPERATION; fi + if ((rollback_status != 0)); then + printf 'КРИТИЧНО: проверка отката не выполнена\n' >&2 + status=$EXIT_ROLLBACK + elif (( TRANSACTION_ROLLBACK_INTERRUPTED == 1 )); then + printf 'КРИТИЧНО: откат прерван сигналом %s\n' "$TRANSACTION_ROLLBACK_SIGNAL" >&2 + status=$EXIT_ROLLBACK + else + status=$EXIT_OPERATION + fi fi + TRANSACTION_ROLLBACK_ACTIVE=0 fi if (( status == 0 )) && [[ -n ${backup_path:-} ]]; then if ! prune_backups; then @@ -922,10 +993,371 @@ run_transaction() { fi rm -f -- "$result_file" TRANSACTION_BACKUP_PATH=${result_backup:-} - unset TRANSACTION_RESULT_FILE TRANSACTION_PRE_LOCK_FILE TRANSACTION_PRE_WG_CONF TRANSACTION_PRE_BACKUP_DIR TRANSACTION_PRE_METADATA_DIR TRANSACTION_PRE_INTERFACE + unset TRANSACTION_RESULT_FILE TRANSACTION_PRE_LOCK_FILE TRANSACTION_PRE_WG_CONF TRANSACTION_PRE_BACKUP_DIR TRANSACTION_PRE_METADATA_DIR TRANSACTION_PRE_INTERFACE TRANSACTION_FINALIZER return "$result_status" } +validate_client_name() { + local name=$1 + [[ -n $name && ${#name} -le 64 && $name =~ ^[A-Za-z0-9._-]+$ && $name != -* ]] || return 1 + is_control_free "$name" +} + +require_add_privilege() { + if (( EUID == 0 )); then return 0; fi + transaction_test_hooks_enabled || { + die "$EXIT_PERMISSION" 'Для добавления клиента требуются права root' + return $? + } +} + +client_paths_available() { + local name=$1 path + for path in "${METADATA_DIR}/${name}.meta" "${CLIENT_DIR}/${name}.conf" "${CLIENT_DIR}/${name}.key" "${CLIENT_DIR}/${name}.pub" "${CLIENT_DIR}/${name}.psk"; do + [[ ! -e $path && ! -L $path ]] || return 1 + done +} + +client_name_conflict() { + local name=$1 key + for key in "${!META_NAME[@]}"; do [[ ${META_NAME[$key]} == "$name" ]] && return 0; done + for key in "${!DISK_MARKER[@]}"; do [[ ${DISK_MARKER[$key]} == "$name" || ${DISK_LEGACY[$key]} == "$name" ]] && return 0; done + return 1 +} + +allocate_client_ipv4() { + local host candidate key allowed conflict + for ((host = 10#$START_HOST; host <= 10#$END_HOST; host++)); do + candidate="${VPN_SUBNET_PREFIX}.${host}/32" + conflict=0 + for key in "${!DISK_ALLOWED[@]}"; do + allowed=${DISK_ALLOWED[$key]} + if allowed_sets_overlap "$candidate" "$allowed"; then conflict=1; break; fi + done + if (( conflict == 0 )); then + for key in "${!META_IPV4[@]}"; do + if allowed_sets_overlap "$candidate" "${META_IPV4[$key]}/32"; then conflict=1; break; fi + done + fi + if (( conflict == 0 )); then printf '%s\n' "$candidate"; return 0; fi + done + return 1 +} + +allocate_client_ipv6() { + local host=${1##*.} hex candidate key allowed + [[ $USE_IPV6 == 1 ]] || return 0 + candidate=$(printf '%s%s/128' "$IPV6_PREFIX" "$(printf '%x' "$host")") + for key in "${!DISK_ALLOWED[@]}"; do + allowed=${DISK_ALLOWED[$key]} + if allowed_sets_overlap "$candidate" "$allowed"; then return 1; fi + done + for key in "${!META_IPV6[@]}"; do + [[ -n ${META_IPV6[$key]} ]] || continue + if cidrs_overlap "$candidate" "${META_IPV6[$key]}/128"; then return 1; fi + done + printf '%s\n' "$candidate" +} + +client_allowed_ips() { + case $1 in + full-tunnel) printf '0.0.0.0/0,::/0' ;; + split-tunnel) printf '%s.0/24' "$VPN_SUBNET_PREFIX" ;; + *) return 1 ;; + esac +} + +CAPTURED_KEY='' +capture_single_key() { + local mode=$1 input=$2 path line + local -a lines=() + shift 2 + CAPTURED_KEY='' + path=$(mktemp "${WG_CONF%/*}/.wg-manager.key.XXXXXX") || return "$EXIT_OPERATION" + chmod 600 -- "$path" || { rm -f -- "$path"; return "$EXIT_OPERATION"; } + TRANSACTION_TEMP_PATHS+=("$path") + if [[ $mode == stdin ]]; then + if ! printf '%s\n' "$input" | "$@" >"$path"; then rm -f -- "$path"; return "$EXIT_OPERATION"; fi + elif ! "$@" >"$path"; then + rm -f -- "$path" + return "$EXIT_OPERATION" + fi + mapfile -t lines <"$path" + rm -f -- "$path" + ((${#lines[@]} == 1)) || return "$EXIT_OPERATION" + line=${lines[0]} + is_wireguard_key "$line" || return "$EXIT_OPERATION" + CAPTURED_KEY=$line +} + +read_server_public_key() { + capture_single_key none '' wg show "$WG_INTERFACE" public-key || return "$EXIT_OPERATION" + printf '%s\n' "$CAPTURED_KEY" +} + +generate_client_keys() { + local private public psk status=0 xtrace=0 + [[ $- == *x* ]] && xtrace=1 + set +x + if capture_single_key none '' wg genkey; then private=$CAPTURED_KEY; else status=$EXIT_OPERATION; fi + if (( status == 0 )); then if capture_single_key stdin "$private" wg pubkey; then public=$CAPTURED_KEY; else status=$EXIT_OPERATION; fi; fi + if (( status == 0 )); then if capture_single_key none '' wg genpsk; then psk=$CAPTURED_KEY; else status=$EXIT_OPERATION; fi; fi + (( status == 0 )) || { (( xtrace == 1 )) && set -x; return "$status"; } + ADD_PRIVATE_KEY=$private ADD_PUBLIC_KEY=$public ADD_PSK=$psk + (( xtrace == 1 )) && set -x + return 0 +} + +create_add_stage_file() { + local directory=$1 prefix=$2 path kind + kind=${prefix##*.} + if [[ ${TRANSACTION_ADD_STAGE_FAIL:-} == "$prefix" || ${TRANSACTION_ADD_STAGE_FAIL:-} == "$kind" ]] && transaction_test_hooks_enabled; then return "$EXIT_OPERATION"; fi + path=$(mktemp "$directory/.wg-manager.add.${prefix}.XXXXXX") || return "$EXIT_OPERATION" + if [[ ! -f "$path" || -L "$path" ]] || ! chmod 600 -- "$path"; then + rm -f -- "$path" + return "$EXIT_OPERATION" + fi + TRANSACTION_TEMP_PATHS+=("$path") + printf '%s\n' "$path" +} + +add_client_builder() { + local stage=$1 name=$ADD_NAME mode=$ADD_MODE endpoint=$ADD_ENDPOINT ipv4 ipv6 server_public allowed_client allowed_server + local meta_file config_file key_file pub_file psk_file + set +x + [[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return "$EXIT_OPERATION" + validate_client_name "$name" || return "$EXIT_VALIDATION" + parse_wireguard_config "$WG_CONF" || return $? + parse_metadata || return $? + if client_name_conflict "$name"; then + add_failure "$EXIT_VALIDATION" 'имя клиента уже используется' + return $? + fi + if ! client_paths_available "$name"; then + add_failure "$EXIT_VALIDATION" 'целевой файл клиента уже существует или небезопасен' + return $? + fi + if ! ipv4=$(allocate_client_ipv4); then + add_failure "$EXIT_VALIDATION" 'диапазон IPv4-адресов клиентов исчерпан' + return $? + fi + if ! ipv6=$(allocate_client_ipv6 "${ipv4%/*}"); then + add_failure "$EXIT_VALIDATION" 'адрес IPv6 клиента конфликтует или недоступен' + return $? + fi + if ! allowed_client=$(client_allowed_ips "$mode"); then + add_failure "$EXIT_VALIDATION" 'некорректный режим адресов клиента' + return $? + fi + if ! server_public=$(read_server_public_key); then + add_failure "$EXIT_OPERATION" 'не удалось получить открытый ключ сервера' + return $? + fi + if ! generate_client_keys; then + add_failure "$EXIT_OPERATION" 'не удалось сгенерировать ключи клиента' + return $? + fi + if [[ -n ${DISK_ALLOWED[$ADD_PUBLIC_KEY]+x} ]]; then + add_failure "$EXIT_VALIDATION" 'сгенерированный открытый ключ уже используется' + return $? + fi + if [[ -n ${META_NAME[$ADD_PUBLIC_KEY]+x} ]]; then + add_failure "$EXIT_VALIDATION" 'сгенерированный открытый ключ уже есть в метаданных' + return $? + fi + allowed_server="$ipv4"; [[ -n $ipv6 ]] && allowed_server+=",$ipv6" + meta_file=${ADD_META_STAGE:?metadata stage is not prepared} + config_file=${ADD_CONFIG_STAGE:?config stage is not prepared} + key_file=${ADD_KEY_STAGE:?key stage is not prepared} + pub_file=${ADD_PUB_STAGE:?public key stage is not prepared} + psk_file=${ADD_PSK_STAGE:?psk stage is not prepared} + TRANSACTION_TEMP_PATHS+=("$meta_file" "$config_file" "$key_file" "$pub_file" "$psk_file") + { + cat -- "$WG_CONF" + printf '\n# wg-manager-client: %s\n[Peer]\nPublicKey = %s\nPresharedKey = %s\nAllowedIPs = %s\n' "$name" "$ADD_PUBLIC_KEY" "$ADD_PSK" "$allowed_server" + } >"$stage" + printf '%s\n' "$ADD_PRIVATE_KEY" >"$key_file" + printf '%s\n' "$ADD_PUBLIC_KEY" >"$pub_file" + printf '%s\n' "$ADD_PSK" >"$psk_file" + { + printf '[Interface]\nPrivateKey = %s\nAddress = %s' "$ADD_PRIVATE_KEY" "$allowed_server" + printf '\nDNS = %s\n\n[Peer]\nPublicKey = %s\nPresharedKey = %s\nEndpoint = %s:%s\nAllowedIPs = %s\nPersistentKeepalive = 25\n' "$CLIENT_DNS" "$server_public" "$ADD_PSK" "$endpoint" "$SERVER_PORT" "$allowed_client" + } >"$config_file" + printf 'NAME=%s\nPUBLIC_KEY=%s\nIPV4=%s\n' "$name" "$ADD_PUBLIC_KEY" "${ipv4%/*}" >"$meta_file" + [[ -n $ipv6 ]] && printf 'IPV6=%s\n' "${ipv6%/*}" >>"$meta_file" + printf 'CREATED_AT=%s\nCONFIG_PATH=%s\nCLIENT_MODE=%s\nMANAGED=1\n' "$(current_epoch)" "${CLIENT_DIR}/${name}.conf" "$mode" >>"$meta_file" + chmod 600 -- "$meta_file" "$config_file" "$key_file" "$pub_file" "$psk_file" +} + +publish_add_artifacts() { + local name=${ADD_NAME:?add name is not set} stage_path final_path published=() + local -a stages=("$ADD_META_STAGE" "$ADD_CONFIG_STAGE" "$ADD_KEY_STAGE" "$ADD_PUB_STAGE" "$ADD_PSK_STAGE") + local -a finals=("${METADATA_DIR}/${name}.meta" "${CLIENT_DIR}/${name}.conf" "${CLIENT_DIR}/${name}.key" "${CLIENT_DIR}/${name}.pub" "${CLIENT_DIR}/${name}.psk") + local index + if [[ ${ADD_PUBLISH_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then + add_failure "$EXIT_OPERATION" 'не удалось опубликовать файлы клиента' + return "$EXIT_OPERATION" + fi + for index in "${!stages[@]}"; do + stage_path=${stages[$index]}; final_path=${finals[$index]} + if (( index == 0 )) && [[ ${ADD_PUBLISH_RACE:-0} == 1 ]] && transaction_test_hooks_enabled; then + printf 'administrator collision\n' >"$final_path" + chmod 600 -- "$final_path" + fi + if [[ ! -f "$stage_path" || -L "$stage_path" || -e "$final_path" || -L "$final_path" ]]; then + add_failure "$EXIT_OPERATION" 'конфликт целевого файла клиента при публикации' + for stage_path in "${stages[@]}" "${published[@]}"; do rm -f -- "$stage_path"; done + return "$EXIT_OPERATION" + fi + if ! ln -- "$stage_path" "$final_path"; then + add_failure "$EXIT_OPERATION" 'не удалось опубликовать файл клиента' + for stage_path in "${stages[@]}" "${published[@]}"; do rm -f -- "$stage_path"; done + return "$EXIT_OPERATION" + fi + TRANSACTION_OWNED_PATHS+=("$final_path") + if ! rm -f -- "$stage_path"; then + add_failure "$EXIT_OPERATION" 'не удалось завершить публикацию файла клиента' + rm -f -- "$final_path" + for stage_path in "${stages[@]}" "${published[@]}"; do rm -f -- "$stage_path"; done + return "$EXIT_OPERATION" + fi + published+=("$final_path") + if (( index == 0 || index == 2 || index == 4 )) && [[ -n ${ADD_PUBLISH_READY_FIFO:-} ]] && [[ -z ${ADD_PUBLISH_READY_INDEX:-} || ${ADD_PUBLISH_READY_INDEX} == "$index" ]]; then + if ! transaction_test_hooks_enabled; then + add_failure "$EXIT_OPERATION" "некорректная синхронизация публикации" + return "$EXIT_OPERATION" + fi + if ! [[ ${ADD_PUBLISH_READY_FIFO} == "${TEST_ROOT}"/* && ! -L ${ADD_PUBLISH_READY_FIFO} && -p ${ADD_PUBLISH_READY_FIFO} && ${ADD_PUBLISH_RELEASE_FIFO:-} == "${TEST_ROOT}"/* && ! -L ${ADD_PUBLISH_RELEASE_FIFO} && -p ${ADD_PUBLISH_RELEASE_FIFO} ]]; then + add_failure "$EXIT_OPERATION" "некорректная синхронизация публикации" + return "$EXIT_OPERATION" + fi + if [[ -n ${ADD_PUBLISH_PID_FILE:-} ]]; then + if ! [[ ${ADD_PUBLISH_PID_FILE} == "${TEST_ROOT}"/* && ! -L ${ADD_PUBLISH_PID_FILE} && ( ! -e ${ADD_PUBLISH_PID_FILE} || -f ${ADD_PUBLISH_PID_FILE} ) ]]; then + add_failure "$EXIT_OPERATION" "некорректный файл синхронизации публикации" + return "$EXIT_OPERATION" + fi + if ! printf '%s\n' "$BASHPID" >"$ADD_PUBLISH_PID_FILE"; then + add_failure "$EXIT_OPERATION" "не удалось подготовить синхронизацию публикации" + return "$EXIT_OPERATION" + fi + fi + if ! printf 'ready\n' >"$ADD_PUBLISH_READY_FIFO"; then + add_failure "$EXIT_OPERATION" "не удалось подготовить синхронизацию публикации" + return "$EXIT_OPERATION" + fi + if ! IFS= read -r _ <"$ADD_PUBLISH_RELEASE_FIFO"; then + add_failure "$EXIT_OPERATION" "синхронизация публикации прервана" + return "$EXIT_OPERATION" + fi + fi + done + return 0 +} + +clear_add_transaction_state() { + local path + for path in "$ADD_META_STAGE" "$ADD_CONFIG_STAGE" "$ADD_KEY_STAGE" "$ADD_PUB_STAGE" "$ADD_PSK_STAGE"; do + [[ -n ${path:-} ]] && rm -f -- "$path" + done + unset ADD_NAME ADD_MODE ADD_ENDPOINT ADD_PRIVATE_KEY ADD_PUBLIC_KEY ADD_PSK ADD_META_STAGE ADD_CONFIG_STAGE ADD_KEY_STAGE ADD_PUB_STAGE ADD_PSK_STAGE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX + TRANSACTION_FINALIZER='' +} + +add_client_impl() { + local name mode endpoint arg mode_seen=0 endpoint_seen=0 + (($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; } + name=$1; shift + load_config || return $? + mode=$DEFAULT_CLIENT_MODE; endpoint=$SERVER_ENDPOINT + while (($#)); do + case $1 in + --mode) + (( mode_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --mode указан повторно'; return $?; } + (($# >= 2)) || { die "$EXIT_USAGE" 'Для --mode требуется значение'; return $?; } + mode=$2; mode_seen=1; shift 2 ;; + --endpoint) + (( endpoint_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --endpoint указан повторно'; return $?; } + (($# >= 2)) || { die "$EXIT_USAGE" 'Для --endpoint требуется значение'; return $?; } + endpoint=$2; endpoint_seen=1; shift 2 ;; + *) die "$EXIT_USAGE" 'Неизвестный или лишний аргумент add'; return $? ;; + esac + done + validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; } + [[ $mode == full-tunnel || $mode == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; } + validate_endpoint "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный endpoint клиента'; return $?; } + require_add_privilege || return $? + ADD_META_STAGE='' ADD_CONFIG_STAGE='' ADD_KEY_STAGE='' ADD_PUB_STAGE='' ADD_PSK_STAGE='' + if [[ ${TRANSACTION_ADD_DIRECTORY_FAIL:-} == mkdir ]] && transaction_test_hooks_enabled; then + add_failure "$EXIT_OPERATION" 'не удалось подготовить каталоги клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + if ! mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR"; then + add_failure "$EXIT_OPERATION" 'не удалось создать каталоги клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + if [[ ${TRANSACTION_ADD_DIRECTORY_FAIL:-} == chmod ]] && transaction_test_hooks_enabled; then + add_failure "$EXIT_OPERATION" 'не удалось защитить каталоги клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + if ! chmod 700 -- "$METADATA_DIR" "$CLIENT_DIR"; then + add_failure "$EXIT_OPERATION" 'не удалось защитить каталоги клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + if ! ADD_META_STAGE=$(create_add_stage_file "$METADATA_DIR" "${name}.meta"); then + add_failure "$EXIT_OPERATION" 'не удалось подготовить метаданные клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + if ! ADD_CONFIG_STAGE=$(create_add_stage_file "$CLIENT_DIR" "${name}.conf"); then + add_failure "$EXIT_OPERATION" 'не удалось подготовить конфигурацию клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + if ! ADD_KEY_STAGE=$(create_add_stage_file "$CLIENT_DIR" "${name}.key"); then + add_failure "$EXIT_OPERATION" 'не удалось подготовить закрытый ключ клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + if ! ADD_PUB_STAGE=$(create_add_stage_file "$CLIENT_DIR" "${name}.pub"); then + add_failure "$EXIT_OPERATION" 'не удалось подготовить открытый ключ клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + if ! ADD_PSK_STAGE=$(create_add_stage_file "$CLIENT_DIR" "${name}.psk"); then + add_failure "$EXIT_OPERATION" 'не удалось подготовить дополнительный ключ клиента' + clear_add_transaction_state + return "$EXIT_OPERATION" + fi + ADD_NAME=$name ADD_MODE=$mode ADD_ENDPOINT=$endpoint + TRANSACTION_FINALIZER=publish_add_artifacts + local status + if run_transaction add "$name" add_client_builder; then + status=0 + else + status=$? + fi + if (( status != 0 )); then + clear_add_transaction_state + return "$status" + fi + status=0 + clear_add_transaction_state + return "$status" +} + +add_client() { + local caller_xtrace=0 status + [[ $- == *x* ]] && caller_xtrace=1 + set +x + if add_client_impl "$@"; then status=0; else status=$?; fi + (( caller_xtrace == 1 )) && set -x + return "$status" +} + print_status() { local managed=0 unmanaged=0 active=0 index for index in "${!CLIENT_KEYS[@]}"; do @@ -964,8 +1396,23 @@ print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; } run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; } main() { + local status + if (( $# == 0 )); then + if die "$EXIT_USAGE" 'Ожидается команда'; then status=0; else status=$?; fi + print_usage >&2 + return "$status" + fi + if [[ $1 == add ]]; then + shift + if (( $# == 0 )); then + if die "$EXIT_USAGE" 'Ожидается имя клиента'; then status=0; else status=$?; fi + print_usage >&2 + return "$status" + fi + add_client "$@" + return $? + fi if (( $# != 1 )); then - local status if die "$EXIT_USAGE" 'Ожидается одна команда'; then status=0; else status=$?; fi print_usage >&2 return "$status" @@ -975,9 +1422,8 @@ main() { version) print_version ;; 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" ;; + 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 print_usage >&2 return "$status"