feat(clients): add safe client removal and archival
This commit is contained in:
@@ -266,7 +266,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
|
|||||||
| M3 | DONE | Locking, backups, atomic apply, and rollback engine |
|
| M3 | DONE | Locking, backups, atomic apply, and rollback engine |
|
||||||
| M4 | DONE | Safe client creation |
|
| M4 | DONE | Safe client creation |
|
||||||
| M5 | DONE | Client detail, config export, and QR display |
|
| 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 |
|
| M7 | NOT STARTED | Diagnostics engine and report |
|
||||||
| M8 | NOT STARTED | Backup listing/restoration and audit log UX |
|
| M8 | NOT STARTED | Backup listing/restoration and audit log UX |
|
||||||
| M9 | NOT STARTED | TUI foundation, dashboard, and read-only screens |
|
| 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`
|
- Suggested commit message: `feat(clients): add client details and secure config export`
|
||||||
- Next milestone: M6
|
- 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
|
# 8. Final completion rule
|
||||||
|
|
||||||
The MVP is complete only when:
|
The MVP is complete only when:
|
||||||
|
|||||||
@@ -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;
|
inspection form that writes the complete client configuration to stdout;
|
||||||
ordinary `show`, `config`, and `qr` output remains secret-safe.
|
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` для ошибки операции или архивации. Ошибка аудита после успешной мутации явно сообщается и возвращает операционный статус; отмена остаётся успешной без изменения состояния.
|
||||||
|
|||||||
+453
-3
@@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh"
|
|||||||
|
|
||||||
case $# in
|
case $# in
|
||||||
0) ;;
|
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 ;;
|
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
|
||||||
esac
|
esac
|
||||||
test_group=${1-}
|
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}/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=/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 отклонён'
|
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 отклонён"
|
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
|
done
|
||||||
mkdir -- "${TEST_ROOT}/hook-real"; ln -s -- "${TEST_ROOT}/hook-real" "${TEST_ROOT}/hook-link"
|
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-компонентом отклонён"
|
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
|
done
|
||||||
rm -rf -- "${TEST_ROOT}/hook-real" "${TEST_ROOT}/hook-link"
|
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
|
scan_sources_for_absolute_external_commands
|
||||||
fi
|
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
|
if [[ $test_group == config ]]; then
|
||||||
run_config_tests
|
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
|
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
|
[[ $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
|
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
|
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
|
else
|
||||||
run_config_tests
|
run_config_tests
|
||||||
run_gum_tests
|
run_gum_tests
|
||||||
@@ -2364,6 +2812,8 @@ else
|
|||||||
test_group=show-client
|
test_group=show-client
|
||||||
run_m5_tests
|
run_m5_tests
|
||||||
test_group=''
|
test_group=''
|
||||||
|
run_m6_tests
|
||||||
|
test_group=''
|
||||||
fi
|
fi
|
||||||
|
|
||||||
finish_tests
|
finish_tests
|
||||||
|
|||||||
+300
-7
@@ -54,7 +54,7 @@ print_usage() {
|
|||||||
Использование:
|
Использование:
|
||||||
wg-manager.sh help
|
wg-manager.sh help
|
||||||
wg-manager.sh version
|
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 будут добавлены в следующих версиях.
|
Команды управления WireGuard будут добавлены в следующих версиях.
|
||||||
USAGE
|
USAGE
|
||||||
@@ -583,7 +583,7 @@ transaction_test_hooks_enabled() {
|
|||||||
config_path=${CONFIG_PATH:-${WG_MANAGER_CONFIG:-}}
|
config_path=${CONFIG_PATH:-${WG_MANAGER_CONFIG:-}}
|
||||||
[[ -n $config_path && $config_path == "${TEST_ROOT%/}"/* ]] || return 1
|
[[ -n $config_path && $config_path == "${TEST_ROOT%/}"/* ]] || return 1
|
||||||
path_components_safe "$config_path" || 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
|
[[ -n $path && $path == "${TEST_ROOT%/}"/* ]] && path_components_safe "$path" || return 1
|
||||||
done
|
done
|
||||||
for path in "${ADD_PUBLISH_READY_FIFO:-}" "${ADD_PUBLISH_RELEASE_FIFO:-}" "${ADD_PUBLISH_PID_FILE:-}"; do
|
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() {
|
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 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
|
local transaction_path
|
||||||
shift 3
|
shift 3
|
||||||
TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH=''
|
TRANSACTION_TEMP_PATHS=(); TRANSACTION_BACKUP_PATH=''
|
||||||
@@ -928,10 +928,12 @@ run_transaction_core() {
|
|||||||
TRANSACTION_OWNED_PATHS=()
|
TRANSACTION_OWNED_PATHS=()
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
status=$EXIT_OPERATION
|
finalizer_status=$?
|
||||||
|
printf 'Ошибка: не удалось завершить финальную операцию транзакции\n' >&2
|
||||||
|
status=$finalizer_status
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} ]]; then
|
if [[ -n ${TRANSACTION_PENDING_SIGNAL:-} && $status != $EXIT_ROLLBACK ]]; then
|
||||||
printf 'Ошибка: операция прервана сигналом %s\n' "$TRANSACTION_PENDING_SIGNAL" >&2
|
printf 'Ошибка: операция прервана сигналом %s\n' "$TRANSACTION_PENDING_SIGNAL" >&2
|
||||||
status=$EXIT_OPERATION
|
status=$EXIT_OPERATION
|
||||||
fi
|
fi
|
||||||
@@ -952,7 +954,7 @@ run_transaction_core() {
|
|||||||
printf 'КРИТИЧНО: откат прерван сигналом %s\n' "$TRANSACTION_ROLLBACK_SIGNAL" >&2
|
printf 'КРИТИЧНО: откат прерван сигналом %s\n' "$TRANSACTION_ROLLBACK_SIGNAL" >&2
|
||||||
status=$EXIT_ROLLBACK
|
status=$EXIT_ROLLBACK
|
||||||
else
|
else
|
||||||
status=$EXIT_OPERATION
|
if (( finalizer_status == EXIT_ROLLBACK )); then status=$EXIT_ROLLBACK; else status=$EXIT_OPERATION; fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
TRANSACTION_ROLLBACK_ACTIVE=0
|
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() {
|
client_paths_available() {
|
||||||
local name=$1 path
|
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
|
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"; }
|
(( 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() {
|
main() {
|
||||||
local status
|
local status
|
||||||
if (( $# == 0 )); then
|
if (( $# == 0 )); then
|
||||||
@@ -1565,7 +1854,11 @@ main() {
|
|||||||
version) print_version ;;
|
version) print_version ;;
|
||||||
status) load_client_model || return $?; print_status ;;
|
status) load_client_model || return $?; print_status ;;
|
||||||
list) load_client_model || return $?; print_list ;;
|
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
|
if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi
|
||||||
print_usage >&2
|
print_usage >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user