diff --git a/PLANS.md b/PLANS.md index 92f08bc..43c1fe2 100644 --- a/PLANS.md +++ b/PLANS.md @@ -266,7 +266,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`. | M3 | DONE | Locking, backups, atomic apply, and rollback engine | | M4 | DONE | Safe client creation | | M5 | DONE | Client detail, config export, and QR display | -| M6 | NOT STARTED | Safe client removal and archival | +| M6 | DONE | 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 | @@ -1635,6 +1635,20 @@ Use this template: - Suggested commit message: `feat(clients): add client details and secure config export` - Next milestone: M6 +## 2026-10-13 — M6 — DONE + +- Implemented: managed-only `remove NAME [--yes]`; safe interactive and noninteractive confirmation; exact public-key/marker/address block removal with post-lock identity revalidation; M3 transactional apply and rollback; restrictive timestamped archival of associated client artifacts; recovery handling for partial archival; and identity-protected, secret-free removal audit events. +- Coordinator: `gpt-5.6-sol` (`high`) +- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`) +- Repair cycles: 5 +- Sol review: replaced aliased and tautological focused tests with exact neighbor preservation and isolated identity cases; added privilege and confirmation gates; hardened archive source, owner, mode, hardlink, symlink, collision, partial-copy, partial-delete, and recovery handling; added post-lock metadata revalidation; protected audit files with restrictive ownership and opened-FD identity checks; preserved critical artifact-recovery status through server rollback; corrected test contamination, false original-path assertions, deterministic timestamps, and documentation ordering. +- Verification: `bash -n wg-manager.sh`, `bash -n tests/run.sh`, `bash -n tests/helpers.sh`, `bash -n tests/mocks/bin/*`, `bash tests/run.sh remove-client`, `bash tests/run.sh remove-cancel`, `bash tests/run.sh remove-exact-block`, `bash tests/run.sh remove-rollback`, `bash tests/run.sh archive-client`, `bash tests/run.sh archive-path-safety`, `bash tests/run.sh remove-preserves-neighbors`, `bash tests/run.sh no-secret-logs`, `bash tests/run.sh`, `git diff --check`, permission, scope, and secret-pattern review. +- Result: all required checks passed; remove-client/remove-cancel/remove-exact-block/remove-rollback/archive-client/archive-path-safety/remove-preserves-neighbors/no-secret-logs passed 17/16/5/7/58/31/5/41 assertions, and the full regression suite passed 461 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, real terminal confirmation, and deployment-server behavior were not accessed or tested; M6 verification is fixture- and mock-based as required. Permanent purge is intentionally not implemented. +- Suggested commit message: `feat(clients): add safe client removal and archival` +- Next milestone: M7 + # 8. Final completion rule The MVP is complete only when: diff --git a/README.md b/README.md index 2c4261c..da46d54 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,10 @@ for an operational read/export/QR failure. `config NAME --show` is the only inspection form that writes the complete client configuration to stdout; ordinary `show`, `config`, and `qr` output remains secret-safe. +## M6 удаление клиента + +`remove NAME [--yes]` удаляет только подтверждённый managed-peer после повторной проверки точного публичного ключа, адресов и блока под lock. В интерактивном терминале без `--yes` показываются имя, адреса и только последние 8 символов публичного ключа; ответ, отличный от `y`, отменяет операцию без изменений. В неинтерактивном режиме `--yes` обязателен. + +Изменение серверной конфигурации выполняется внутри транзакции: сначала конфигурация заменяется и проверяется в WireGuard, а затем финализатор архивирует связанные `.conf`, `.meta`, `.key`, `.pub` и `.psk` (если существуют) в уникальный каталог `ARCHIVE_DIR` с режимами `0700`/`0600`. Ошибка архивирования после применения приводит к откату и оставляет исходные файлы доступными для восстановления; архивы не удаляются автоматически и служат восстановительным материалом. Журнал содержит operation, target, UTC timestamp, outcome и короткий суффикс публичного ключа; ключи и содержимое конфигураций в него не записываются. + +`remove` возвращает `64` для ошибки аргументов, `65` для неподтверждённой или небезопасной identity/path, `69` для отсутствующей обязательной зависимости и `70` для ошибки операции или архивации. Ошибка аудита после успешной мутации явно сообщается и возвращает операционный статус; отмена остаётся успешной без изменения состояния. diff --git a/tests/run.sh b/tests/run.sh index 85af0fd..d47812b 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh" case $# in 0) ;; - 1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;; + 1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac test_group=${1-} @@ -111,11 +111,11 @@ run_config_tests() { 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 + for hook_path_name in CLIENT_DIR METADATA_DIR BACKUP_DIR LOCK_FILE LOG_DIR ARCHIVE_DIR; 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 + for hook_path_name in WG_CONF CLIENT_DIR METADATA_DIR BACKUP_DIR LOCK_FILE LOG_DIR ARCHIVE_DIR; 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" @@ -2342,6 +2342,452 @@ scan_test_sources_for_host_path scan_sources_for_absolute_external_commands fi +run_m6_tests() { + local config_file=$TEST_ROOT/m6.conf fixture=$TEST_ROOT/wg0.conf runtime_dir=$TEST_ROOT/dumps + local output status before archive_dir + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"; chmod 600 -- "$fixture" + sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" + sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" + write_m2_config "$config_file" "$fixture" + replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/client-files\"" + 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 WG_MANAGER_TEST_MODE=1 + export WG_MOCK_INTERFACE=wg0 TEST_MOCK_LOG=$TEST_ROOT/mocks.log WG_MOCK_DUMP_SEQUENCE_DIR=$runtime_dir + set_runtime_paths + load_config + rm -rf -- "$TEST_ROOT/clients" "$TEST_ROOT/client-files" "$TEST_ROOT/archive" "$TEST_ROOT/logs" "$runtime_dir" + mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" "$runtime_dir"; chmod 700 -- "$METADATA_DIR" "$CLIENT_DIR" "$runtime_dir" + write_alice_metadata; chmod 600 -- "$METADATA_DIR/alice.meta" + printf '[Interface]\nAddress = 192.0.2.2/32\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.conf" + printf 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.key" + printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' >"$CLIENT_DIR/alice.pub" + printf 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' >"$CLIENT_DIR/alice.psk"; chmod 600 -- "$CLIENT_DIR"/alice.* + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/1" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/3" + if [[ $test_group == remove-exact-block || $test_group == remove-preserves-neighbors ]]; then + cat >"$fixture" <<'EOF' +[Interface] +Address = 192.0.2.1/24 +ListenPort = 51820 +PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + +# wg-manager-client: before +[Peer] +PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= +AllowedIPs = 192.0.2.3/32 +Endpoint = before.example.invalid:51820 + +# wg-manager-client: alice +[Peer] +PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= +AllowedIPs = 192.0.2.2/32 +Endpoint = vpn.example.invalid:51820 + +# wg-manager-client: after +[Peer] +PublicKey = DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD= +AllowedIPs = 192.0.2.4/32 +Endpoint = after.example.invalid:51820 +EOF + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=\t(none)\tbefore.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=\t(none)\tafter.example.invalid:51820\t192.0.2.4/32\t0\t0\t0\t0\n' >"$runtime_dir/1" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=\t(none)\tbefore.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\nDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=\t(none)\tafter.example.invalid:51820\t192.0.2.4/32\t0\t0\t0\t0\n' >"$runtime_dir/2" + begin_test 'remove exact block preserves byte-identical neighboring peers' + local before_neighbor after_neighbor after_before after_after + before_neighbor=$(awk '/# wg-manager-client: before/,/Endpoint = before.example.invalid:51820/' "$fixture") + after_neighbor=$(awk '/# wg-manager-client: after/{on=1} on{print}' "$fixture") + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal 0 "$status" 'удаление точного блока с соседями' + after_before=$(awk '/# wg-manager-client: before/,/Endpoint = before.example.invalid:51820/' "$fixture") + after_after=$(awk '/# wg-manager-client: after/{on=1} on{print}' "$fixture") + assert_equal "$before_neighbor" "$after_before" 'предшествующий peer сохранён побайтно' + assert_equal "$after_neighbor" "$after_after" 'следующий peer сохранён побайтно' + assert_equal '' "$(grep -E 'BBBB|wg-manager-client: alice|192\.0\.2\.2/32' "$fixture" || true)" 'target block removed completely' + assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'neighbor removal output is secret-free' + elif [[ $test_group == remove-client ]]; then + begin_test 'remove identity and argument refusals preserve the source' + local remove_before=$(sha256sum -- "$fixture") + set +e; output=$(remove_client alice extra 2>&1); status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'лишний аргумент remove отклонён' + assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'лишний аргумент не меняет конфиг' + set +e; output=$(/bin/bash "$PROJECT_DIR/wg-manager.sh" remove 2>&1); status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'отсутствующее имя remove отклонено' + set +e; output=$(remove_client absent --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'отсутствующий клиент отклонён' + assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'отсутствующий клиент не меняет конфиг' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/### Client legacy/' "$fixture" + rm -f -- "$METADATA_DIR/alice.meta" + set +e; output=$(remove_client legacy --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'legacy peer без metadata отклонён' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" + sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" + write_alice_metadata + replace_config_line "$METADATA_DIR/alice.meta" 'PUBLIC_KEY=' 'PUBLIC_KEY=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'metadata public key mismatch отклонён' + assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'public key mismatch не меняет конфиг' + write_alice_metadata + replace_config_line "$METADATA_DIR/alice.meta" 'IPV4=' 'IPV4=192.0.2.99' + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'несовпадение адреса managed identity отклонено' + assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'несовпадение identity не меняет конфиг' + cat >>"$fixture" <<'EOF' + +# wg-manager-client: alice +[Peer] +PublicKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= +AllowedIPs = 192.0.2.3/32 +EOF + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_dir/1" + rm -f -- "$runtime_dir/counter" + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'duplicate display name is refused' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" + sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" + write_alice_metadata + replace_config_line "$METADATA_DIR/alice.meta" 'PUBLIC_KEY=' 'PUBLIC_KEY=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' + replace_config_line "$METADATA_DIR/alice.meta" 'IPV4=' 'IPV4=192.0.2.3' + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"$runtime_dir/1" + rm -f -- "$runtime_dir/counter" + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'runtime-only identity is refused' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/### Client alice/' "$fixture" + write_alice_metadata + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'legacy marker is not a managed identity' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" + sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" + write_alice_metadata + printf '\n# wg-manager-client: alice\n[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nAllowedIPs = 192.0.2.2/32\nEndpoint = vpn.example.invalid:51820\n' >>"$fixture" + local duplicate_before=$(sha256sum -- "$fixture") + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'duplicate exact managed block is refused' + assert_equal "$duplicate_before" "$(sha256sum -- "$fixture")" 'duplicate block leaves source unchanged' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" + sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" + write_alice_metadata + begin_test 'locked identity revalidation rejects metadata changed after confirmation' + load_client_model + local race_index race_key race_path + race_index=$(resolve_named_client alice); race_key=${CLIENT_KEYS[$race_index]}; race_path=${META_CONFIG[$race_key]} + REMOVE_NAME=alice REMOVE_KEY=$race_key REMOVE_CONFIG_PATH=$race_path REMOVE_META_FILE=${META_FILE[$race_key]} REMOVE_ALLOWED=${DISK_ALLOWED[$race_key]} + remove_builder_mutate() { replace_config_line "$REMOVE_META_FILE" 'IPV4=' 'IPV4=192.0.2.99'; parse_metadata; remove_peer_builder "$1"; } + local race_before=$(sha256sum -- "$fixture") + set +e; output=$(run_transaction remove alice remove_builder_mutate "$REMOVE_META_FILE" 2>&1); status=$?; set -e + unset -f remove_builder_mutate + assert_equal "$EXIT_VALIDATION" "$status" 'изменение metadata после lock отклонено' + assert_equal "$race_before" "$(sha256sum -- "$fixture")" 'race не изменяет серверный конфиг' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" + sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" + write_alice_metadata + elif [[ $test_group == remove-cancel ]]; then + begin_test 'explicit cancellation helper leaves removal untouched' + local cancel_before=$(sha256sum -- "$fixture") + set +e; output=$(printf 'n\n' | confirm_remove alice 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' '192.0.2.2/32' 2>&1); status=$?; set -e + assert_equal 1 "$status" 'отмена подтверждения возвращает отказ' + assert_contains 'Удалить клиента alice' "$output" 'подтверждение показывает имя' + assert_equal "$cancel_before" "$(sha256sum -- "$fixture")" 'отмена не меняет конфиг' + elif [[ -z $test_group || $test_group == remove-client || $test_group == archive-client ]]; then + begin_test 'managed remove archives artifacts and removes exact peer' + before=$(sha256sum -- "$fixture") + local meta_before conf_before key_before pub_before psk_before + meta_before=$(sha256sum -- "$METADATA_DIR/alice.meta") + conf_before=$(sha256sum -- "$CLIENT_DIR/alice.conf") + key_before=$(sha256sum -- "$CLIENT_DIR/alice.key") + pub_before=$(sha256sum -- "$CLIENT_DIR/alice.pub") + psk_before=$(sha256sum -- "$CLIENT_DIR/alice.psk") + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal 0 "$status" 'успешное удаление managed клиента' + assert_equal '' "$(grep -F 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$fixture" || true)" 'точный peer удалён' + archive_dir=$(find "$TEST_ROOT/archive" -mindepth 1 -maxdepth 1 -type d -print -quit) + assert_equal 0 "$([[ -f $archive_dir/alice.meta ]] && printf 0 || printf 1)" 'метаданные архивированы' + for archived_name in alice.meta alice.conf alice.key alice.pub alice.psk; do + local original_path + if [[ $archived_name == alice.meta ]]; then original_path="$METADATA_DIR/alice.meta"; else original_path="$CLIENT_DIR/$archived_name"; fi + assert_equal 0 "$([[ -f $archive_dir/$archived_name && $(stat -c '%a' -- "$archive_dir/$archived_name") == 600 ]] && printf 0 || printf 1)" "архивный ${archived_name} защищён" + assert_equal 1 "$([[ -e $original_path || -L $original_path ]] && printf 0 || printf 1)" "исходный ${archived_name} удалён" + done + local archived_hash + archived_hash=$(sha256sum -- "$archive_dir/alice.meta"); assert_equal "${meta_before%% *}" "${archived_hash%% *}" 'архив метаданных побайтно совпадает' + archived_hash=$(sha256sum -- "$archive_dir/alice.conf"); assert_equal "${conf_before%% *}" "${archived_hash%% *}" 'архив конфига побайтно совпадает' + archived_hash=$(sha256sum -- "$archive_dir/alice.key"); assert_equal "${key_before%% *}" "${archived_hash%% *}" 'архив ключа побайтно совпадает' + archived_hash=$(sha256sum -- "$archive_dir/alice.pub"); assert_equal "${pub_before%% *}" "${archived_hash%% *}" 'архив public побайтно совпадает' + archived_hash=$(sha256sum -- "$archive_dir/alice.psk"); assert_equal "${psk_before%% *}" "${archived_hash%% *}" 'архив PSK побайтно совпадает' + assert_equal 700 "$(stat -c '%a' -- "$archive_dir")" 'каталог архива защищён' + assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' <<<"$output" || true)" 'вывод удаления без секретов' + assert_contains 'operation=remove' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит удаления' + assert_contains 'client=alice' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит содержит client' + assert_contains 'actor_uid=' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит содержит actor uid' + assert_contains 'error=success' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит содержит outcome category' + assert_contains 'timestamp=2023-11-14T22:15:00Z' "$(<"$TEST_ROOT/logs/wg-manager.log")" 'аудит содержит детерминированное UTC-время' + assert_equal '' "$(grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_ROOT/logs/wg-manager.log" || true)" 'аудит без секретов' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" + sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" + write_alice_metadata + printf '[Interface]\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\nAddress = 192.0.2.2/32\n' >"$CLIENT_DIR/alice.conf" + printf 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.key" + printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' >"$CLIENT_DIR/alice.pub" + printf 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' >"$CLIENT_DIR/alice.psk" + chmod 600 -- "$CLIENT_DIR"/* "$METADATA_DIR/alice.meta" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/1" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/3" + rm -f -- "$runtime_dir/counter" + local fail_meta fail_conf fail_key fail_pub fail_psk archive_count + fail_meta=$(sha256sum -- "$METADATA_DIR/alice.meta"); fail_conf=$(sha256sum -- "$CLIENT_DIR/alice.conf") + fail_key=$(sha256sum -- "$CLIENT_DIR/alice.key"); fail_pub=$(sha256sum -- "$CLIENT_DIR/alice.pub"); fail_psk=$(sha256sum -- "$CLIENT_DIR/alice.psk") + archive_count=$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) + export M6_ARCHIVE_COPY_FAIL_INDEX=1 + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + unset M6_ARCHIVE_COPY_FAIL_INDEX + assert_equal "$EXIT_OPERATION" "$status" 'ошибка архивации возвращает operation' + assert_contains 'Ошибка:' "$output" 'ошибка архивации имеет диагностику' + assert_contains 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$(<"$fixture")" 'при ошибке архивации peer восстановлен' + assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'исходные артефакты доступны для восстановления' + for artifact in "$METADATA_DIR/alice.meta" "$CLIENT_DIR/alice.conf" "$CLIENT_DIR/alice.key" "$CLIENT_DIR/alice.pub" "$CLIENT_DIR/alice.psk"; do + assert_equal 600 "$(stat -c '%a' -- "$artifact")" "copy failure сохраняет режим ${artifact##*/}" + done + assert_equal "${fail_meta%% *}" "$(sha256sum -- "$METADATA_DIR/alice.meta" | cut -d' ' -f1)" 'copy failure сохраняет metadata bytes' + assert_equal "${fail_conf%% *}" "$(sha256sum -- "$CLIENT_DIR/alice.conf" | cut -d' ' -f1)" 'copy failure сохраняет config bytes' + assert_equal "$archive_count" "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)" 'copy failure cleans incomplete archive' + export M6_ARCHIVE_COPY_FAIL_INDEX=4 + rm -f -- "$runtime_dir/counter" + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + unset M6_ARCHIVE_COPY_FAIL_INDEX + assert_equal "$EXIT_OPERATION" "$status" 'ошибка финального копирования возвращает operation' + assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'финальная ошибка сохраняет исходные артефакты' + assert_equal "${fail_key%% *}" "$(sha256sum -- "$CLIENT_DIR/alice.key" | cut -d' ' -f1)" 'final copy failure сохраняет key bytes' + assert_equal "${fail_pub%% *}" "$(sha256sum -- "$CLIENT_DIR/alice.pub" | cut -d' ' -f1)" 'final copy failure сохраняет pub bytes' + assert_equal "${fail_psk%% *}" "$(sha256sum -- "$CLIENT_DIR/alice.psk" | cut -d' ' -f1)" 'final copy failure сохраняет psk bytes' + rm -f -- "$runtime_dir/counter" + export M6_ARCHIVE_REMOVE_FAIL_INDEX=1 + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + unset M6_ARCHIVE_REMOVE_FAIL_INDEX + assert_equal "$EXIT_OPERATION" "$status" 'ошибка удаления после частичной архивации возвращает operation' + assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $CLIENT_DIR/alice.key && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'частичная архивация восстановила исходные файлы' + assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'ошибка удаления без секретов' + begin_test 'artifact recovery failure remains critical after server rollback' + rm -f -- "$runtime_dir/counter" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/1" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/3" + export M6_ARCHIVE_REMOVE_FAIL_INDEX=1 M6_ARCHIVE_RECOVERY_FAIL_INDEX=1 + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + unset M6_ARCHIVE_REMOVE_FAIL_INDEX M6_ARCHIVE_RECOVERY_FAIL_INDEX + assert_equal "$EXIT_ROLLBACK" "$status" 'ошибка recovery сохраняет critical status' + assert_contains 'КРИТИЧНО' "$output" 'ошибка recovery явно critical' + assert_contains 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$(<"$fixture")" 'server rollback выполнен при recovery failure' + assert_equal 0 "$( [[ -n $(find "$ARCHIVE_DIR" -mindepth 2 -type f \( -name alice.meta -o -name alice.conf \) -print -quit) ]] && printf 0 || printf 1 )" 'recovery archive remains available' + assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'recovery failure без секретов' + write_alice_metadata + printf '[Interface]\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\nAddress = 192.0.2.2/32\n' >"$CLIENT_DIR/alice.conf" + printf 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n' >"$CLIENT_DIR/alice.key" + printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\n' >"$CLIENT_DIR/alice.pub" + printf 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\n' >"$CLIENT_DIR/alice.psk" + chmod 600 -- "$CLIENT_DIR"/* "$METADATA_DIR/alice.meta" + begin_test 'post-mutation audit failure reports partial success safely' + rm -f -- "$runtime_dir/counter" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/1" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" + export M6_AUDIT_FAIL=1 + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + unset M6_AUDIT_FAIL + assert_equal "$EXIT_OPERATION" "$status" 'ошибка аудита после mutation возвращает operation' + assert_contains 'частичный результат' "$output" 'ошибка аудита явно обозначает partial success' + assert_equal '' "$(grep -F 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$fixture" || true)" 'при audit failure peer остаётся удалённым' + assert_equal 1 "$( [[ ! -e $CLIENT_DIR/alice.conf && ! -e $METADATA_DIR/alice.meta ]] && printf 1 || printf 0 )" 'при audit failure исходные артефакты архивированы' + assert_equal '' "$(grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|EEEE|GGGG|PrivateKey|PresharedKey' <<<"$output" || true)" 'audit failure без секретов' + cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture" + sed -i 's/wg-manager-client: fixture/wg-manager-client: alice/' "$fixture" + sed -i '/\[Peer\]/{N; s/\[Peer\]\n# wg-manager-client: alice/# wg-manager-client: alice\n[Peer]/;}' "$fixture" + write_alice_metadata + printf '[Interface]\nPrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\nAddress = 192.0.2.2/32\n' >"$CLIENT_DIR/alice.conf" + chmod 600 -- "$CLIENT_DIR/alice.conf" + begin_test 'optional client artifacts are not fabricated' + rm -f -- "$CLIENT_DIR/alice.key" "$CLIENT_DIR/alice.pub" "$CLIENT_DIR/alice.psk" "$runtime_dir/counter" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/1" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/2" + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal 0 "$status" 'удаление без optional artifacts успешно' + local optional_archive + optional_archive=$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -n | tail -n1 | cut -d' ' -f2-) + assert_equal 1 "$( [[ -f $optional_archive/alice.meta && -f $optional_archive/alice.conf && ! -e $optional_archive/alice.key && ! -e $optional_archive/alice.pub && ! -e $optional_archive/alice.psk ]] && printf 1 || printf 0 )" 'optional artifacts не создаются в архиве' + fi + if [[ $test_group == remove-cancel ]]; then + begin_test 'simulated interactive cancellation previews safe identity' + remove_is_interactive() { return 0; } + local interactive_before=$(sha256sum -- "$fixture") + set +e; output=$(printf 'n\n' | remove_client alice 2>&1); status=$?; set -e + unset -f remove_is_interactive + remove_is_interactive() { [[ -t 0 && -t 1 ]]; } + assert_equal 0 "$status" 'интерактивная отмена успешна' + assert_contains 'Удалить клиента alice' "$output" 'preview содержит имя' + assert_contains '192.0.2.2/32' "$output" 'preview содержит адрес' + assert_contains '*BBBBBBB=' "$output" 'preview содержит только суффикс ключа' + assert_equal '' "$(grep -E 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'preview без секретов' + assert_equal "$interactive_before" "$(sha256sum -- "$fixture")" 'интерактивная отмена без изменений' + begin_test 'cancelled removal survives audit failure without mutation' + remove_is_interactive() { return 0; } + export M6_AUDIT_FAIL=1 + set +e; output=$(printf 'n\n' | remove_client alice 2>&1); status=$?; set -e + unset M6_AUDIT_FAIL + unset -f remove_is_interactive + remove_is_interactive() { [[ -t 0 && -t 1 ]]; } + assert_equal 0 "$status" 'отмена при ошибке аудита остаётся успешной' + assert_contains 'аудит не записан' "$output" 'ошибка аудита отмены предупреждается' + assert_equal "$interactive_before" "$(sha256sum -- "$fixture")" 'ошибка аудита отмены без изменений' + begin_test 'noninteractive removal requires explicit confirmation' + before=$(sha256sum -- "$fixture") + set +e; remove_client alice >/dev/null 2>"$TEST_ROOT/cancel.err"; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'без --yes требуется подтверждение' + assert_equal "$before" "$(sha256sum -- "$fixture")" 'отмена не меняет конфиг' + set +e; remove_client alice --yes --yes >/dev/null 2>"$TEST_ROOT/cancel.err"; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'дублированный --yes отклонён' + set +e; remove_client alice --bad >/dev/null 2>"$TEST_ROOT/cancel.err"; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'неизвестный флаг отклонён' + fi + if [[ $test_group == remove-rollback ]]; then + begin_test 'apply failure rolls back peer and preserves artifacts' + cp -- "$runtime_dir/3" "$runtime_dir/2" + export WG_SYNC_FAIL_ONCE_FILE=$TEST_ROOT/sync-fail + set +e; remove_client alice --yes >/dev/null 2>"$TEST_ROOT/rollback.err"; status=$?; set -e + assert_equal "$EXIT_OPERATION" "$status" 'ошибка apply возвращает operation' + assert_contains 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$(<"$fixture")" 'peer восстановлен' + assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'артефакты сохранены' + unset WG_SYNC_FAIL_ONCE_FILE + begin_test 'post-apply verification failure rolls back disk runtime and artifacts' + local rollback_before=$(sha256sum -- "$fixture") + rm -f -- "$runtime_dir/counter" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51819\t0\n' >"$runtime_dir/1" + printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_dir/2" + set +e; output=$(remove_client alice --yes 2>&1); status=$?; set -e + assert_equal "$EXIT_OPERATION" "$status" 'ошибка post-apply verification возвращает operation' + assert_equal "$rollback_before" "$(sha256sum -- "$fixture")" 'verification rollback restores exact config' + assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf && -f $METADATA_DIR/alice.meta ]] && printf 0 || printf 1)" 'verification rollback сохраняет артефакты' + assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' <<<"$output" || true)" 'verification failure is secret-free' + fi + if [[ $test_group == archive-path-safety ]]; then + begin_test 'unsafe archive path is rejected' + local archive_link=$TEST_ROOT/archive-link + ln -s -- "$TEST_ROOT" "$archive_link"; export WG_MANAGER_ARCHIVE_DIR=$archive_link + set +e; remove_client alice --yes >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'symlink archive rejected' + assert_equal 0 "$([[ -f $CLIENT_DIR/alice.conf ]] && printf 0 || printf 1)" 'при unsafe archive файлы сохранены' + export WG_MANAGER_ARCHIVE_DIR="$TEST_ROOT/archive" + REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= REMOVE_CONFIG_PATH="$CLIENT_DIR/alice.conf" REMOVE_META_FILE="$METADATA_DIR/alice.meta" + begin_test 'archive validates source identity, modes, links, and collisions' + printf 'sentinel\n' >"$TEST_ROOT/archive-sentinel" + mv -- "$CLIENT_DIR/alice.conf" "$CLIENT_DIR/alice.real.conf" + ln -s -- "$TEST_ROOT/archive-sentinel" "$CLIENT_DIR/alice.conf" + set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'symlink source config rejected' + assert_equal sentinel "$(<"$TEST_ROOT/archive-sentinel")" 'symlink target preserved' + rm -- "$CLIENT_DIR/alice.conf"; mv -- "$CLIENT_DIR/alice.real.conf" "$CLIENT_DIR/alice.conf" + chmod 644 "$CLIENT_DIR/alice.conf" + set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'permissive source config rejected' + chmod 600 "$CLIENT_DIR/alice.conf" + local archive_real_stat + archive_real_stat=$(command -v stat) + stat() { + if [[ ${2:-} == '%u:%g' && ${4:-} == "$CLIENT_DIR/alice.conf" ]]; then printf '999:999\n'; return 0; fi + "$archive_real_stat" "$@" + } + set +e; output=$(archive_remove_artifacts 2>&1); status=$?; set -e + unset -f stat + assert_equal "$EXIT_VALIDATION" "$status" 'owner mismatch source rejected' + assert_contains 'небезопасные атрибуты' "$output" 'owner mismatch archive diagnostic' + ln -- "$CLIENT_DIR/alice.conf" "$TEST_ROOT/alice.conf.link" + set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked source config rejected' + rm -- "$TEST_ROOT/alice.conf.link" + mv -- "$METADATA_DIR/alice.meta" "$METADATA_DIR/alice.real.meta" + ln -s -- "$TEST_ROOT/archive-sentinel" "$METADATA_DIR/alice.meta" + set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'symlink source metadata rejected' + rm -- "$METADATA_DIR/alice.meta"; mv -- "$METADATA_DIR/alice.real.meta" "$METADATA_DIR/alice.meta" + chmod 644 "$METADATA_DIR/alice.meta" + set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'permissive source metadata rejected' + chmod 600 "$METADATA_DIR/alice.meta" + ln -- "$METADATA_DIR/alice.meta" "$TEST_ROOT/alice.meta.link" + set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked source metadata rejected' + rm -- "$TEST_ROOT/alice.meta.link" + printf 'old\n' >"$TEST_ROOT/archive-existing" + rm -f -- "$TEST_ROOT/archive-existing" + local archive_stamp + archive_stamp=$(date -u -d @1700000100 +%Y%m%dT%H%M%SZ) + rm -rf -- "$ARCHIVE_DIR/${archive_stamp}-wg0-alice" + mkdir -- "$ARCHIVE_DIR/${archive_stamp}-wg0-alice"; chmod 700 -- "$ARCHIVE_DIR/${archive_stamp}-wg0-alice" + write_alice_metadata + set +e; archive_remove_artifacts >/dev/null 2>"$TEST_ROOT/archive.err"; status=$?; set -e + assert_equal 0 "$status" 'archive collision receives a unique suffix' + assert_equal 0 "$([[ -d $ARCHIVE_DIR/${archive_stamp}-wg0-alice ]] && printf 0 || printf 1)" 'existing archive preserved' + assert_equal "${ARCHIVE_DIR}/${archive_stamp}-wg0-alice-1" "$REMOVE_ARCHIVE_PATH" 'archive uses exact collision suffix' + for archived_name in alice.meta alice.conf alice.key alice.pub alice.psk; do + assert_equal 0 "$([[ -f ${REMOVE_ARCHIVE_PATH}/${archived_name} && $(stat -c '%a' -- "${REMOVE_ARCHIVE_PATH}/${archived_name}") == 600 ]] && printf 0 || printf 1)" "архив содержит ${archived_name} с режимом 600" + done + assert_equal 700 "$(stat -c '%a' -- "$ARCHIVE_DIR")" 'корень архива имеет режим 700' + assert_equal 700 "$(stat -c '%a' -- "$REMOVE_ARCHIVE_PATH")" 'каталог архива имеет режим 700' + begin_test 'audit rejects symlinked log objects without mutation' + local audit_target=$TEST_ROOT/audit-sentinel + printf 'sentinel\n' >"$audit_target" + rm -rf -- "$LOG_DIR" + ln -s -- "$audit_target" "$LOG_DIR" + set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e + assert_failure "$status" 'symlinked log directory rejected' + assert_equal sentinel "$(<"$audit_target")" 'audit symlink target preserved' + rm -f -- "$LOG_DIR"; mkdir -- "$LOG_DIR"; chmod 700 -- "$LOG_DIR" + ln -s -- "$audit_target" "$LOG_DIR/wg-manager.log" + set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e + assert_failure "$status" 'symlinked audit file rejected' + assert_equal sentinel "$(<"$audit_target")" 'audit file target preserved' + rm -f -- "$LOG_DIR/wg-manager.log" + begin_test 'audit log directory mode and owner are validated' + chmod 755 -- "$LOG_DIR" + set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'permissive log directory rejected' + chmod 700 -- "$LOG_DIR" + local log_real_stat + log_real_stat=$(command -v stat) + stat() { + if [[ ${2:-} == '%u:%g' && ${4:-} == "$LOG_DIR" ]]; then printf '999:999\n'; return 0; fi + "$log_real_stat" "$@" + } + set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e + unset -f stat + assert_equal "$EXIT_VALIDATION" "$status" 'owner mismatch log directory rejected' + begin_test 'audit validates opened FD identity and restrictive attributes' + printf 'existing\n' >"$LOG_DIR/wg-manager.log"; chmod 600 -- "$LOG_DIR/wg-manager.log" + local audit_real_stat audit_before + audit_real_stat=$(command -v stat); audit_before=$(<"$LOG_DIR/wg-manager.log") + stat() { + if [[ ${1:-} == -Lc && ${4:-} == /proc/self/fd/* ]]; then printf '999:999:600:999:999:1\n'; return 0; fi + "$audit_real_stat" "$@" + } + set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e + unset -f stat + assert_equal "$EXIT_VALIDATION" "$status" 'audit FD identity mismatch rejected' + assert_equal "$audit_before" "$(<"$LOG_DIR/wg-manager.log")" 'FD mismatch writes no bytes' + chmod 644 -- "$LOG_DIR/wg-manager.log" + set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'permissive audit file rejected' + chmod 600 -- "$LOG_DIR/wg-manager.log" + ln -- "$LOG_DIR/wg-manager.log" "$TEST_ROOT/audit-hardlink" + set +e; output=$(REMOVE_NAME=alice REMOVE_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= remove_audit failed 2>&1); status=$?; set -e + assert_equal "$EXIT_VALIDATION" "$status" 'hardlinked audit file rejected' + rm -f -- "$TEST_ROOT/audit-hardlink" "$LOG_DIR/wg-manager.log" + fi +} + if [[ $test_group == config ]]; then run_config_tests elif [[ $test_group == show-client || $test_group == config-export || $test_group == qr || $test_group == path-safety || $test_group == no-secret-default-output ]]; then @@ -2353,6 +2799,8 @@ elif [[ $test_group == transaction || $test_group == rollback || $test_group == [[ $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 +elif [[ $test_group == remove-client || $test_group == remove-cancel || $test_group == remove-exact-block || $test_group == remove-rollback || $test_group == archive-client || $test_group == archive-path-safety || $test_group == remove-preserves-neighbors ]]; then + run_m6_tests else run_config_tests run_gum_tests @@ -2364,6 +2812,8 @@ else test_group=show-client run_m5_tests test_group='' + run_m6_tests + test_group='' fi finish_tests diff --git a/wg-manager.sh b/wg-manager.sh index 5565516..e8a6193 100755 --- a/wg-manager.sh +++ b/wg-manager.sh @@ -54,7 +54,7 @@ print_usage() { Использование: wg-manager.sh help wg-manager.sh version - wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove|diagnose|logs|backups|restore|tui} + wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose|logs|backups|restore|tui} Команды управления WireGuard будут добавлены в следующих версиях. USAGE @@ -583,7 +583,7 @@ transaction_test_hooks_enabled() { 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 + for path in "${WG_CONF:-}" "${CLIENT_DIR:-}" "${METADATA_DIR:-}" "${BACKUP_DIR:-}" "${LOCK_FILE:-}" "${LOG_DIR:-}" "${ARCHIVE_DIR:-}"; 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 @@ -841,7 +841,7 @@ transaction_subshell_exit() { run_transaction_core() { local operation=$1 target=$2 builder=$3 metadata lock_parent lock_fd status=0 rollback_status stage new_stripped old_stripped original_copy prior_dump current_dump - local source_mode source_uid source_gid backup_path + local source_mode source_uid source_gid backup_path finalizer_status=0 local transaction_path shift 3 TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH='' @@ -928,10 +928,12 @@ run_transaction_core() { TRANSACTION_OWNED_PATHS=() fi else - status=$EXIT_OPERATION + finalizer_status=$? + printf 'Ошибка: не удалось завершить финальную операцию транзакции\n' >&2 + status=$finalizer_status fi fi - if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} ]]; then + if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} && $status != $EXIT_ROLLBACK ]]; then printf 'Ошибка: операция прервана сигналом %s\n' "$TRANSACTION_PENDING_SIGNAL" >&2 status=$EXIT_OPERATION fi @@ -952,7 +954,7 @@ run_transaction_core() { printf 'КРИТИЧНО: откат прерван сигналом %s\n' "$TRANSACTION_ROLLBACK_SIGNAL" >&2 status=$EXIT_ROLLBACK else - status=$EXIT_OPERATION + if (( finalizer_status == EXIT_ROLLBACK )); then status=$EXIT_ROLLBACK; else status=$EXIT_OPERATION; fi fi fi TRANSACTION_ROLLBACK_ACTIVE=0 @@ -1012,6 +1014,14 @@ require_add_privilege() { } } +require_remove_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 @@ -1523,6 +1533,285 @@ qr_client() { (( status == 0 )) || { die "$EXIT_OPERATION" 'Не удалось сформировать QR-код'; return "$EXIT_OPERATION"; } } +short_public_key() { local key=$1; printf '%s' "${key: -8}"; } + +remove_peer_builder() { + local stage=$1 target_key=${REMOVE_KEY:?} target_name=${REMOVE_NAME:?} + local -a lines=() kept=() block=() + local line index peer_start block_start block_end cursor=0 key marker allowed normalized matched=0 metadata_allowed + [[ ${META_NAME[$target_key]:-} == "$target_name" && ${META_MANAGED[$target_key]:-0} == 1 ]] || { die "$EXIT_VALIDATION" 'Identity клиента изменилась после lock'; return $?; } + [[ ${META_FILE[$target_key]:-} == "${REMOVE_META_FILE:-}" && ${META_CONFIG[$target_key]:-} == "${REMOVE_CONFIG_PATH:-}" ]] || { die "$EXIT_VALIDATION" 'Путь клиента изменился после lock'; return $?; } + normalized=$(normalize_allowed_ips "${DISK_ALLOWED[$target_key]:-}") || { die "$EXIT_VALIDATION" 'Адреса клиента изменились после lock'; return $?; } + [[ $normalized == "${REMOVE_ALLOWED:-}" ]] || { die "$EXIT_VALIDATION" 'Адреса клиента изменились после lock'; return $?; } + metadata_allowed=${META_IPV4[$target_key]:-} + [[ -n $metadata_allowed ]] && metadata_allowed+='/32' + [[ -n ${META_IPV6[$target_key]:-} ]] && { [[ -n $metadata_allowed ]] && metadata_allowed+=','; metadata_allowed+=${META_IPV6[$target_key]}/128; } + normalized=$(normalize_allowed_ips "$metadata_allowed") || { die "$EXIT_VALIDATION" 'Адреса metadata изменились после lock'; return $?; } + [[ $normalized == "${REMOVE_ALLOWED:-}" ]] || { die "$EXIT_VALIDATION" 'Адреса metadata изменились после lock'; return $?; } + mapfile -t lines <"$WG_CONF" || return "$EXIT_OPERATION" + while :; do + peer_start=-1 + for index in "${!lines[@]}"; do + (( index >= cursor )) || continue + if [[ ${lines[$index]} =~ ^[[:space:]]*\[Peer\][[:space:]]*$ ]]; then peer_start=$index; break; fi + done + if (( peer_start < 0 )); then + kept+=("${lines[@]:$cursor}") + break + fi + block_start=$peer_start + if (( peer_start > 0 )) && [[ ${lines[$((peer_start - 1))]} == "# wg-manager-client: ${target_name}" ]]; then + block_start=$((peer_start - 1)) + if (( block_start < cursor )) && ((${#kept[@]} > 0)) && [[ ${kept[$((${#kept[@]} - 1))]} == "# wg-manager-client: ${target_name}" ]]; then + unset 'kept[${#kept[@]}-1]' + fi + fi + if (( block_start > cursor )); then kept+=("${lines[@]:$cursor:$((block_start - cursor))}"); fi + block_end=${#lines[@]} + for index in "${!lines[@]}"; do + (( index > peer_start )) || continue + if [[ ${lines[$index]} =~ ^[[:space:]]*\[Peer\][[:space:]]*$ || ${lines[$index]} =~ ^[[:space:]]*\[Interface\][[:space:]]*$ ]]; then block_end=$index; break; fi + done + block=("${lines[@]:$block_start:$((block_end - block_start))}") + key=''; marker=''; allowed='' + for line in "${block[@]}"; do + [[ $line =~ ^[[:space:]]*PublicKey[[:space:]]*=[[:space:]]*(.*)$ ]] && key=$(trim_space "${BASH_REMATCH[1]}") + [[ $line =~ ^[[:space:]]*AllowedIPs[[:space:]]*=[[:space:]]*(.*)$ ]] && allowed=$(trim_space "${BASH_REMATCH[1]}") + if [[ ${block[0]:-} == "# wg-manager-client: ${target_name}" ]]; then marker=1; fi + done + normalized=$(normalize_allowed_ips "$allowed") || return "$EXIT_VALIDATION" + if [[ $key == "$target_key" && $marker == 1 && $normalized == "${REMOVE_ALLOWED:-}" ]]; then + # A conventional marker immediately before the next [Peer] belongs to + # that next peer and must survive removal of this block. + local trailing_index=$(( ${#block[@]} - 1 )) + while (( trailing_index >= 0 )) && [[ -z ${block[$trailing_index]} ]]; do ((trailing_index -= 1)); done + if (( trailing_index >= 0 )) && [[ ${block[$trailing_index]} =~ ^#\ (wg-manager-client:|###\ Client\ ) ]] && [[ ${block[$trailing_index]} != "# wg-manager-client: ${target_name}" ]]; then + kept+=("${block[$trailing_index]}") + fi + else + kept+=("${block[@]}") + fi + if [[ $key == "$target_key" && $marker == 1 && $normalized == "${REMOVE_ALLOWED:-}" ]]; then ((matched += 1)); fi + cursor=$block_end + done + (( matched == 1 )) || { die "$EXIT_VALIDATION" 'Не найден единственный точный managed-блок клиента'; return $?; } + : >"$stage" || return "$EXIT_OPERATION" + printf '%s\n' "${kept[@]}" >"$stage" +} + +remove_archive_sources() { + local path base mode owner server_owner + REMOVE_ARCHIVE_SOURCES=("$REMOVE_META_FILE" "$REMOVE_CONFIG_PATH") + for base in key pub psk; do + path="${CLIENT_DIR}/${REMOVE_NAME}.${base}" + if [[ -e $path || -L $path ]]; then REMOVE_ARCHIVE_SOURCES+=("$path"); fi + done + server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; } + for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do + [[ -f $path && ! -L $path ]] || { die "$EXIT_VALIDATION" 'Небезопасный файл архивации'; return $?; } + mode=$(stat -c '%a' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить режим файла архивации'; return $?; } + owner=$(stat -c '%u:%g' -- "$path") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца файла архивации'; return $?; } + [[ $mode == 600 && $owner == "$server_owner" && $(stat -c '%h' -- "$path") == 1 ]] || { die "$EXIT_VALIDATION" 'Файл архивации имеет небезопасные атрибуты'; return $?; } + done +} + +allocate_remove_archive() { + local now stamp base candidate suffix + now=$(current_epoch) || { die "$EXIT_OPERATION" 'Не удалось получить время архивации'; return $?; } + stamp=$(date -u -d "@${now}" +%Y%m%dT%H%M%SZ) || { die "$EXIT_OPERATION" 'Не удалось получить UTC-время архивации'; return $?; } + base="${ARCHIVE_DIR}/${stamp}-${WG_INTERFACE}-${REMOVE_NAME}" + for ((suffix = 0; suffix <= 999; suffix++)); do + if (( suffix == 0 )); then candidate=$base; else candidate="${base}-${suffix}"; fi + if mkdir -- "$candidate" 2>/dev/null; then + if ! chmod 700 -- "$candidate"; then + rm -rf -- "$candidate" + die "$EXIT_OPERATION" 'Не удалось защитить каталог архива' + return $? + fi + REMOVE_ARCHIVE_PATH=$candidate + return 0 + fi + done + die "$EXIT_OPERATION" 'Не удалось создать уникальный архив' + return $? +} + +archive_remove_artifacts() { + local path base mode owner server_owner remove_index=0 copy_index=0 + local -a archived=() + set_runtime_paths || return $? + [[ ${REMOVE_NAME:?} =~ ^[A-Za-z0-9._-]+$ ]] || return "$EXIT_VALIDATION" + path_components_safe "$ARCHIVE_DIR" && path_components_safe "$CLIENT_DIR" && path_components_safe "$METADATA_DIR" || { die "$EXIT_VALIDATION" 'Каталоги архивации содержат небезопасный путь'; return $?; } + [[ ! -L $ARCHIVE_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог архива является ссылкой'; return $?; } + [[ $REMOVE_META_FILE == "${METADATA_DIR}/${REMOVE_NAME}.meta" && $REMOVE_CONFIG_PATH == "${CLIENT_DIR}/${REMOVE_NAME}.conf" ]] || { die "$EXIT_VALIDATION" 'Файлы клиента не соответствуют его имени'; return $?; } + server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца серверной конфигурации'; return $?; } + if [[ -e $ARCHIVE_DIR ]]; then + [[ -d $ARCHIVE_DIR ]] || { die "$EXIT_VALIDATION" 'Каталог архива недоступен'; return $?; } + mode=$(stat -c '%a' -- "$ARCHIVE_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить режим каталога архива'; return $?; } + owner=$(stat -c '%u:%g' -- "$ARCHIVE_DIR") || { die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога архива'; return $?; } + [[ $mode == 700 && $owner == "$server_owner" ]] || { die "$EXIT_VALIDATION" 'Каталог архива имеет небезопасные атрибуты'; return $?; } + else + mkdir -- "$ARCHIVE_DIR" || { die "$EXIT_OPERATION" 'Не удалось создать каталог архива'; return $?; } + if ! chmod 700 -- "$ARCHIVE_DIR"; then + if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi + die "$EXIT_OPERATION" 'Не удалось защитить каталог архива' + return $? + fi + if ! owner=$(stat -c '%u:%g' -- "$ARCHIVE_DIR"); then + if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi + die "$EXIT_OPERATION" 'Не удалось проверить владельца каталога архива' + return $? + fi + if [[ $owner != "$server_owner" ]]; then + if ! rmdir -- "$ARCHIVE_DIR" 2>/dev/null; then printf 'Предупреждение: не удалось удалить незавершённый каталог архива\n' >&2; fi + die "$EXIT_VALIDATION" 'Владелец каталога архива не совпадает' + return $? + fi + fi + remove_archive_sources || return $? + allocate_remove_archive || return $? + for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do + base=${path##*/} + if [[ ${M6_ARCHIVE_COPY_FAIL_INDEX:-} == "$copy_index" ]] && transaction_test_hooks_enabled; then + rm -rf -- "$REMOVE_ARCHIVE_PATH" + die "$EXIT_OPERATION" 'Не удалось сохранить файл клиента в архив' + return $? + fi + if ! copy_restricted "$path" "${REMOVE_ARCHIVE_PATH}/${base}"; then + rm -rf -- "$REMOVE_ARCHIVE_PATH" + die "$EXIT_OPERATION" 'Не удалось сохранить файл клиента в архив' + return $? + fi + archived+=("$path") + ((copy_index += 1)) + done + for path in "${REMOVE_ARCHIVE_SOURCES[@]}"; do + if [[ ${M6_ARCHIVE_REMOVE_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then + for base in "${archived[@]}"; do + [[ -e $base ]] && continue + if [[ ${M6_ARCHIVE_RECOVERY_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then + die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента' + return $? + fi + cp -- "${REMOVE_ARCHIVE_PATH}/${base##*/}" "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'; return $?; } + chmod 600 -- "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось защитить восстановленный файл'; return $?; } + done + die "$EXIT_OPERATION" 'Не удалось удалить исходный файл клиента после архивации' + return $? + fi + if ! rm -f -- "$path"; then + for base in "${archived[@]}"; do + [[ -e $base ]] && continue + if [[ ${M6_ARCHIVE_RECOVERY_FAIL_INDEX:-} == "$remove_index" ]] && transaction_test_hooks_enabled; then + die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента' + return $? + fi + cp -- "${REMOVE_ARCHIVE_PATH}/${base##*/}" "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось восстановить файл клиента'; return $?; } + chmod 600 -- "$base" || { die "$EXIT_ROLLBACK" 'КРИТИЧНО: не удалось защитить восстановленный файл'; return $?; } + done + die "$EXIT_OPERATION" 'Не удалось завершить архивацию файлов клиента' + return $? + fi + ((remove_index += 1)) + done +} + +remove_audit() { + local outcome=$1 audit_file=${LOG_DIR}/wg-manager.log mode owner server_owner timestamp fd expected_identity opened_identity + path_components_safe "$LOG_DIR" || return "$EXIT_VALIDATION" + [[ ! -L $LOG_DIR ]] || return "$EXIT_VALIDATION" + if [[ ! -e $LOG_DIR ]]; then + mkdir -- "$LOG_DIR" || return "$EXIT_OPERATION" + chmod 700 -- "$LOG_DIR" || return "$EXIT_OPERATION" + fi + [[ -d $LOG_DIR ]] || return "$EXIT_OPERATION" + mode=$(stat -c '%a' -- "$LOG_DIR") || return "$EXIT_OPERATION" + [[ $mode == 700 ]] || return "$EXIT_VALIDATION" + server_owner=$(stat -c '%u:%g' -- "$WG_CONF") || return "$EXIT_OPERATION" + owner=$(stat -c '%u:%g' -- "$LOG_DIR") || return "$EXIT_OPERATION" + [[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION" + if [[ ${M6_AUDIT_FAIL:-0} == 1 ]] && transaction_test_hooks_enabled; then + return "$EXIT_OPERATION" + fi + if [[ -e $audit_file || -L $audit_file ]]; then + [[ -f $audit_file && ! -L $audit_file && $(stat -c '%h' -- "$audit_file") == 1 ]] || return "$EXIT_VALIDATION" + mode=$(stat -c '%a' -- "$audit_file") || return "$EXIT_OPERATION" + [[ $mode == 600 ]] || return "$EXIT_VALIDATION" + owner=$(stat -c '%u:%g' -- "$audit_file") || return "$EXIT_OPERATION" + [[ $owner == "$server_owner" ]] || return "$EXIT_VALIDATION" + else + : >"$audit_file" || return "$EXIT_OPERATION" + chmod 600 -- "$audit_file" || return "$EXIT_OPERATION" + fi + timestamp=$(current_epoch) || return "$EXIT_OPERATION" + timestamp=$(date -u -d "@${timestamp}" +%Y-%m-%dT%H:%M:%SZ) || return "$EXIT_OPERATION" + expected_identity=$(stat -c '%d:%i:%a:%u:%g:%h' -- "$audit_file") || return "$EXIT_OPERATION" + exec {fd}>>"$audit_file" || return "$EXIT_OPERATION" + opened_identity=$(stat -Lc '%d:%i:%a:%u:%g:%h' -- "/proc/self/fd/${fd}") || { exec {fd}>&-; return "$EXIT_OPERATION"; } + [[ $opened_identity == "$expected_identity" ]] || { exec {fd}>&-; return "$EXIT_VALIDATION"; } + if ! printf 'operation=remove client=%s target=%s key_suffix=%s timestamp=%s outcome=%s actor_uid=%s error=%s\n' "$REMOVE_NAME" "$REMOVE_NAME" "$(short_public_key "$REMOVE_KEY")" "$timestamp" "$outcome" "$EUID" "${REMOVE_ERROR_CATEGORY:-$outcome}" >&$fd; then + exec {fd}>&- + return "$EXIT_OPERATION" + fi + exec {fd}>&- +} + +confirm_remove() { + local name=$1 key=$2 addresses=$3 answer + printf 'Удалить клиента %s (ключ *%s, адреса %s)? [y/N] ' "$name" "$(short_public_key "$key")" "$addresses" >&2 + IFS= read -r answer || answer=n + [[ $answer == y || $answer == Y ]] +} + +remove_is_interactive() { [[ -t 0 && -t 1 ]]; } + +remove_client_impl() { + local name=$1 flag=${2:-} index key path status answer + (($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы remove'; return $?; } + validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; } + [[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг remove --yes'; return $?; } + load_client_model || return $? + path_components_safe "$ARCHIVE_DIR" || { die "$EXIT_VALIDATION" 'Каталог архива содержит символическую ссылку'; return $?; } + require_remove_privilege || return $? + index=$(resolve_named_client "$name") || return $? + [[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Удалять можно только managed-клиента'; return $?; } + key=${CLIENT_KEYS[$index]}; path=$(validate_client_config_path "$key" "$name" "$index") || return $? + REMOVE_NAME=$name REMOVE_KEY=$key REMOVE_CONFIG_PATH=$path REMOVE_META_FILE=${META_FILE[$key]} + REMOVE_ALLOWED=${DISK_ALLOWED[$key]} + if [[ -z $flag ]] && ! remove_is_interactive; then + REMOVE_ERROR_CATEGORY=confirmation_required + remove_audit cancelled + die "$EXIT_USAGE" 'В неинтерактивном режиме требуется флаг --yes' + return $? + fi + if [[ -n $flag ]]; then answer=y; else if confirm_remove "$name" "$key" "${CLIENT_ADDRESSES[$index]}"; then answer=y; else answer=n; fi; fi + if [[ $answer != y && $answer != Y ]]; then + REMOVE_ERROR_CATEGORY=cancelled + if ! remove_audit cancelled; then printf 'Предупреждение: отмена выполнена, но аудит не записан\n' >&2; fi + printf 'Операция отменена\n' >&2 + return 0 + fi + TRANSACTION_FINALIZER=archive_remove_artifacts + if run_transaction remove "$name" remove_peer_builder "$REMOVE_META_FILE"; then status=0; else status=$?; fi + if (( status == 0 )); then + REMOVE_ERROR_CATEGORY=success + if ! remove_audit success; then printf 'Предупреждение: удаление выполнено, но не удалось записать аудит (частичный результат)\n' >&2; status=$EXIT_OPERATION; fi + elif { REMOVE_ERROR_CATEGORY=transaction_failed; ! remove_audit failed; }; then + printf 'Предупреждение: операция удаления завершилась ошибкой, аудит не записан\n' >&2 + fi + unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH TRANSACTION_FINALIZER + return "$status" +} + +remove_client() { + local xtrace=0 status + [[ $- == *x* ]] && xtrace=1; set +x + if remove_client_impl "$@"; then status=0; else status=$?; fi + unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH REMOVE_ERROR_CATEGORY TRANSACTION_FINALIZER REMOVE_ARCHIVE_SOURCES + (( xtrace == 1 )) && set -x + return "$status" +} + main() { local status if (( $# == 0 )); then @@ -1565,7 +1854,11 @@ main() { version) print_version ;; status) load_client_model || return $?; print_status ;; list) load_client_model || return $?; print_list ;; - remove|diagnose|logs|backups|restore|tui) run_placeholder "$1" ;; + remove) + shift + (($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; } + remove_client "$@" ;; + diagnose|logs|backups|restore|tui) run_placeholder "$1" ;; *) if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi print_usage >&2