feat(tui): add safe client and backup workflows

This commit is contained in:
2026-10-15 20:58:17 +03:00
parent 06c7171e91
commit a4f530d7a5
5 changed files with 592 additions and 286 deletions
+345 -3
View File
@@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh"
case $# in
0) ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes|gum-resolution|tui-routing|tui-cancel|tui-read-only|tui-no-gum|tui-non-tty|tui-add|tui-remove|tui-client-actions|tui-backup|tui-restore|tui-backend-failure|tui-no-secret-args) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes|gum-resolution|tui-routing|tui-cancel|tui-read-only|tui-no-gum|tui-non-tty|tui-add|tui-remove|tui-client-actions|tui-backup|tui-restore|tui-backend-failure|tui-no-secret-args|symlink-safety|no-host-writes|no-host-command-bypass|m11-e2e|secret-scan) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
esac
test_group=${1-}
@@ -104,6 +104,11 @@ run_config_tests() {
assert_equal "$TEST_ROOT/archive" "$ARCHIVE_DIR" 'путь архива'
assert_equal "$TEST_ROOT/manager.lock" "$LOCK_FILE" 'путь lock-файла'
begin_test 'Bash version guard has explicit supported and rejected branches'
assert_equal 0 "$(wg_manager_bash_supported 4 3 4 3; printf '%s' "$?")" 'supported Bash version'
assert_failure "$(wg_manager_bash_supported 4 3 4 2; printf '%s' "$?")" 'old Bash minor version'
assert_failure "$(wg_manager_bash_supported 4 3 3 9; printf '%s' "$?")" 'old Bash major version'
begin_test 'test hooks require a confined non-symlink root and paths'
assert_equal 1 "$(TEST_ROOT=/ WG_MANAGER_TEST_MODE=1 CONFIG_PATH=/tmp/x transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'TEST_ROOT=/ отклонён'
assert_equal 1 "$(TEST_ROOT=relative WG_MANAGER_TEST_MODE=1 CONFIG_PATH=relative transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'относительный TEST_ROOT отклонён'
@@ -434,7 +439,7 @@ prepare_m10_add_fixture() {
cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/6"
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"${TEST_ROOT}/dump-sequence/1"
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_DUMP_SEQUENCE_DIR="${TEST_ROOT}/dump-sequence"
unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK WG_MOCK_EXPECTED_PRIVATE_KEY
unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK WG_MOCK_EXPECTED_PRIVATE_KEY TRANSACTION_ROLLBACK_REPLACE_FAIL
set_runtime_paths
load_config
rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"
@@ -4080,7 +4085,339 @@ run_m7_tests() {
fi
}
if [[ $test_group == config ]]; then
run_m11_tests() {
local config_file="$TEST_ROOT/m11.conf" fixture="$TEST_ROOT/m11-wg0.conf"
local output status before_tree after_tree outside sentinel
local initial_hash baseline_backup baseline_id m11_dump failed_hash
cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"
chmod 600 -- "$fixture"
write_m2_config "$config_file" "$fixture"
replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/m11-clients\""
export WG_MANAGER_TEST_MODE=1 WG_MANAGER_CONFIG="$config_file"
export WG_MANAGER_BACKUP_DIR="$TEST_ROOT/m11-backups" WG_MANAGER_LOG_DIR="$TEST_ROOT/m11-logs"
export WG_MANAGER_METADATA_DIR="$TEST_ROOT/m11-meta" WG_MANAGER_ARCHIVE_DIR="$TEST_ROOT/m11-archive"
export WG_MANAGER_LOCK_FILE="$TEST_ROOT/m11.lock" WG_MOCK_INTERFACE=wg0
export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump"
unset WG_MANAGER_GUM GUM_SCRIPT_FILE WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE
set_runtime_paths
load_config
rm -rf -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR"
mkdir -p -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR"
chmod 700 -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR"
: >"$TEST_MOCK_LOG"
case $test_group in
symlink-safety)
begin_test 'M11 rejects symlinked runtime/config targets without following them'
outside="$TEST_ROOT/m11-outside"; mkdir -p -- "$outside"
sentinel="$outside/sentinel"; printf 'unchanged\n' >"$sentinel"
mv -- "$config_file" "$TEST_ROOT/m11-manager-real.conf"
ln -s -- "$TEST_ROOT/m11-manager-real.conf" "$config_file"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'config-readable=FAIL' "$output" 'symlinked manager config is rejected'
assert_equal unchanged "$(<"$sentinel")" 'symlink target sentinel remains unchanged'
rm -f -- "$config_file"; mv -- "$TEST_ROOT/m11-manager-real.conf" "$config_file"
mv -- "$BACKUP_DIR" "$TEST_ROOT/m11-backups-real"
ln -s -- "$outside" "$BACKUP_DIR"
set +e; output=$(manual_backup 2>&1); status=$?; set -e
assert_failure "$status" 'symlinked backup directory is rejected'
assert_equal unchanged "$(<"$sentinel")" 'backup symlink target remains unchanged'
rm -f -- "$BACKUP_DIR"; mv -- "$TEST_ROOT/m11-backups-real" "$BACKUP_DIR"
;;
no-host-writes)
begin_test 'M11 isolated lifecycle does not write the sandbox sibling or repository'
local operation_root sibling_root source_snapshot repo_runtime_before repo_runtime_after
operation_root="$TEST_ROOT/operation-root"; sibling_root="$TEST_ROOT/sibling-root"
mkdir -p -- "$operation_root" "$sibling_root/keep"
printf 'sentinel-before\n' >"$sibling_root/keep/sentinel"
cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$operation_root/wg0.conf"
write_m2_config "$operation_root/m11.conf" "$operation_root/wg0.conf"
replace_config_line "$operation_root/m11.conf" 'CLIENT_DIR=' "CLIENT_DIR=\"$operation_root/clients\""
replace_config_line "$operation_root/m11.conf" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="3"'
export WG_MANAGER_CONFIG="$operation_root/m11.conf" WG_MANAGER_BACKUP_DIR="$operation_root/backups" WG_MANAGER_LOG_DIR="$operation_root/logs" WG_MANAGER_METADATA_DIR="$operation_root/metadata" WG_MANAGER_ARCHIVE_DIR="$operation_root/archive" WG_MANAGER_LOCK_FILE="$operation_root/lock"
cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$operation_root/runtime.dump"
mkdir -p -- "$operation_root/dumps"
cp -- "$operation_root/runtime.dump" "$operation_root/dumps/1"
for candidate in 3 4 5 6; do cp -- "$operation_root/runtime.dump" "$operation_root/dumps/$candidate"; done
{ cat "$operation_root/runtime.dump"; printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n'; } >"$operation_root/dumps/2"
export WG_MOCK_DUMP_FILE="$operation_root/runtime.dump" WG_MOCK_DUMP_SEQUENCE_DIR="$operation_root/dumps"
mkdir -p -- "$operation_root/clients" "$operation_root/backups" "$operation_root/logs" "$operation_root/metadata" "$operation_root/archive"
chmod 700 -- "$operation_root/clients" "$operation_root/backups" "$operation_root/logs" "$operation_root/metadata" "$operation_root/archive"
load_config
source_snapshot=$(sha256sum "$sibling_root/keep/sentinel")
repo_runtime_before=$(find "$PROJECT_DIR" -maxdepth 1 -name '.wg-manager.*' -o -name 'wg-manager.log' | LC_ALL=C sort)
set +e; bash "$PROJECT_DIR/wg-manager.sh" status >/dev/null 2>/dev/null; status=$?; set -e
assert_equal 0 "$status" 'isolated status succeeds'
set +e; add_client sandbox-client >/dev/null 2>"$operation_root/add.err"; status=$?; set -e
assert_equal 0 "$status" 'isolated real mutation succeeds'
assert_equal "$source_snapshot" "$(sha256sum "$sibling_root/keep/sentinel")" 'sandbox sibling is unchanged'
assert_equal '' "$(find "$operation_root" -path "$operation_root/*" ! -path "$operation_root/clients/*" ! -path "$operation_root/backups/*" ! -path "$operation_root/logs/*" ! -path "$operation_root/metadata/*" ! -path "$operation_root/archive/*" -type f -name '*wg-manager*' -print)" 'temporary mutation files stay in operation root'
repo_runtime_after=$(find "$PROJECT_DIR" -maxdepth 1 -name '.wg-manager.*' -o -name 'wg-manager.log' | LC_ALL=C sort)
assert_equal "$repo_runtime_before" "$repo_runtime_after" 'repository has no runtime files'
assert_equal '' "$(grep -E '(/etc|/proc|/sys)' "$TEST_MOCK_LOG" || true)" 'mock log contains no host state path'
assert_equal '' "$(grep -E 'wg-quick (up|down|save)|ip (add|del|replace)|systemctl (start|stop|restart)|nft (add|delete|insert|flush)|iptables (-A|-D|-I|-F)|sysctl -w' "$TEST_MOCK_LOG" || true)" 'lifecycle uses no host mutation commands'
local forbidden_host_path="/$(printf 'etc')/$(printf 'wireguard')"
assert_equal '' "$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' "${forbidden_host_path}|/bin/(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|ss)|env -i|bash -c|sh -c|eval[[:space:]]" "$PROJECT_DIR" || true)" 'production has no unscoped host command bypass'
;;
no-host-command-bypass)
begin_test 'M11 sensitive commands resolve to isolated mocks and execute through them'
local failures_before=$TEST_FAILURES command_name candidate path line command_dir safe_dir resolved saved_path bypass_output
command_dir="$TESTS_DIR/mocks/bin"; safe_dir="$TEST_ROOT/safe-bin"; mkdir -p -- "$safe_dir"
for candidate in awk bash cat chmod cp cut date find grep head mkdir mktemp mv paste rg rm sed sha256sum sort stat tail tr; do
ln -s -- "$(command -v "$candidate")" "$safe_dir/$candidate"
done
local bypass_path="$command_dir:$safe_dir"
for command_name in wg wg-quick ip systemctl journalctl ss nft iptables qrencode gum; do
resolved=$(PATH="$bypass_path" command -v "$command_name" || true)
assert_equal "$command_dir/$command_name" "$resolved" "${command_name} resolves to isolated mock"
done
: >"$TEST_MOCK_LOG"
saved_path=$PATH
PATH="$bypass_path"; export PATH
set +e; bypass_output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e
assert_equal 0 "$status" 'controlled PATH status succeeds'
set +e; bypass_output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e
assert_equal 0 "$status" 'controlled PATH diagnose succeeds with isolated mocks'
mkdir -p -- "$TEST_ROOT/bypass-dumps"
cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$TEST_ROOT/bypass-dumps/1"
cp -- "$TEST_ROOT/bypass-dumps/1" "$TEST_ROOT/bypass-dumps/3"
{ cat "$TEST_ROOT/bypass-dumps/1"; printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n'; } >"$TEST_ROOT/bypass-dumps/2"
export WG_MOCK_DUMP_SEQUENCE_DIR='' WG_MOCK_DUMP_FILE="$TEST_ROOT/bypass-dumps/1" WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/bypass-sync-fail"
set +e; bypass_output=$(add_client bypass-client 2>&1); status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'controlled PATH real add failure is contained'
PATH=$saved_path; export PATH
assert_contains 'wg show wg0 dump' "$(<"$TEST_MOCK_LOG")" 'controlled mock recorded wg runtime access'
assert_contains 'wg syncconf wg0' "$(<"$TEST_MOCK_LOG")" 'controlled mock recorded wg mutation apply'
for command_name in wg wg-quick ip systemctl journalctl nft iptables sysctl; do
for candidate in "/bin/${command_name}" "/usr/bin/${command_name}"; do
for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do
[[ -f "$path" ]] || continue
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"=\"$candidate"* ]]; then
record_failure "absolute host command in ${path}: ${candidate}"
break
fi
done <"$path"
done
done
done
assert_equal "$failures_before" "$TEST_FAILURES" 'no absolute host networking/service command'
assert_equal '' "$(grep -E '^(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|sysctl|ss|qrencode|gum) ' "$TEST_MOCK_LOG" | grep -E '(/etc|/proc|/sys)' || true)" 'mock argv has no host-state path'
assert_equal '' "$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' 'env -i|bash -c|sh -c|eval[[:space:]]|(^|[[:space:]])/(bin|usr/bin)/(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|ss|qrencode|gum)([[:space:]]|$)' "$PROJECT_DIR" || true)" 'no direct command bypass forms'
;;
m11-e2e)
begin_test 'M11 fixture matrix covers fresh, mixed, stopped, and malformed states'
cp -- "$PROJECT_DIR/tests/fixtures/m2/no-peers.conf" "$fixture"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e
assert_equal 0 "$status" 'fresh server with no peers is readable'
cp -- "$PROJECT_DIR/tests/fixtures/m2/managed.conf" "$fixture"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e
assert_equal 0 "$status" 'mixed/managed fixture remains readable without mutation'
export WG_MOCK_DUMP_FAIL_ON_CALL=1
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e
assert_equal 0 "$status" 'stopped runtime degrades to disk-only status'
unset WG_MOCK_DUMP_FAIL_ON_CALL
cp -- "$PROJECT_DIR/tests/fixtures/m2/malformed.conf" "$fixture"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e
assert_equal "$EXIT_VALIDATION" "$status" 'malformed persistent config fails closed'
begin_test 'M11 compatibility wrapper is source-safe and delegates one backend'
set +e; output=$(bash "$PROJECT_DIR/add-user.sh" 2>&1); status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'legacy wrapper rejects missing name'
assert_contains 'Устаревший wrapper' "$output" 'legacy wrapper is documented as compatibility path'
assert_equal '' "$(grep -E '/etc|37\.26\.136\.232|PrivateKey|PresharedKey' <<<"$output" || true)" 'wrapper output has no host path or secret'
begin_test 'M11 wrapper is source-safe and forwards an exact argument vector'
local wrapper_dir wrapper_capture probe_output fake_wrapper
wrapper_dir="$TEST_ROOT/wrapper"; wrapper_capture="$TEST_ROOT/wrapper-argv"
mkdir -- "$wrapper_dir"
cp -- "$PROJECT_DIR/add-user.sh" "$wrapper_dir/add-user.sh"
fake_wrapper="$wrapper_dir/wg-manager.sh"
printf '#!/usr/bin/env bash\nprintf "%%s\\0" "$@" >%q\n' "$wrapper_capture" >"$fake_wrapper"
chmod 700 -- "$fake_wrapper" "$wrapper_dir/add-user.sh"
probe_output=$(printf 'before\n'; main() { printf 'caller-main\n'; }; usage() { printf 'caller-usage\n'; }; source "$wrapper_dir/add-user.sh"; main; usage; printf 'after\n')
assert_equal $'before\ncaller-main\ncaller-usage\nafter' "$probe_output" 'sourcing wrapper preserves caller functions'
"$wrapper_dir/add-user.sh" alice vpn.example.invalid
local -a wrapper_args=()
mapfile -d '' -t wrapper_args <"$wrapper_capture"
assert_equal 4 "${#wrapper_args[@]}" 'wrapper forwards four arguments'
assert_equal add "${wrapper_args[0]}" 'wrapper command argument'
assert_equal alice "${wrapper_args[1]}" 'wrapper name argument'
assert_equal --endpoint "${wrapper_args[2]}" 'wrapper endpoint flag'
assert_equal vpn.example.invalid "${wrapper_args[3]}" 'wrapper endpoint value'
set +e; "$wrapper_dir/add-user.sh" >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'wrapper rejects missing name'
set +e; "$wrapper_dir/add-user.sh" one two three >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'wrapper rejects extra arguments'
"$wrapper_dir/add-user.sh" $'-unsafe\nname'
wrapper_args=(); mapfile -d '' -t wrapper_args <"$wrapper_capture"
assert_equal $'-unsafe\nname' "${wrapper_args[1]}" 'wrapper forwards option-like newline name literally'
begin_test 'M11 real add backend produces an inspectable isolated client'
prepare_m10_add_fixture
set +e; add_client m11-client >/dev/null 2>"$TEST_ROOT/m11-add.err"; status=$?; set -e
assert_equal 0 "$status" 'real M11 add succeeds'
assert_file_exists "$CLIENT_DIR/m11-client.conf" 'real M11 client config exists'
assert_file_exists "$METADATA_DIR/m11-client.meta" 'real M11 metadata exists'
set +e; output=$(export_client_config m11-client 0 2>&1); status=$?; set -e
assert_equal 0 "$status" 'real M11 config path is validated'
assert_contains 'm11-client.conf' "$output" 'real M11 config path is shown'
unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/1"
local m11_no_qr utility
m11_no_qr="$TEST_ROOT/m11-no-qr"; mkdir -- "$m11_no_qr"
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg" "$m11_no_qr/wg"
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg-quick" "$m11_no_qr/wg-quick"
for utility in stat sort date cat paste grep awk; do ln -s -- "$(command -v "$utility")" "$m11_no_qr/$utility"; done
local m11_saved_path=$PATH
PATH="$m11_no_qr"; export PATH
set +e; output=$(qr_client m11-client 2>&1); status=$?; set -e
PATH=$m11_saved_path; export PATH
assert_equal "$EXIT_DEPENDENCY" "$status" 'M11 QR fallback reports missing optional dependency'
assert_contains 'm11-client.conf' "$output" 'M11 QR fallback exposes only safe path'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_ROOT/m11-add.err" "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" || true)" 'M11 lifecycle diagnostics remain secret-free'
begin_test 'M11 real lifecycle add, inspect, remove, and restore returns to baseline'
# The add fixture is the same isolated model used by the production M4
# tests. Keep the complete lifecycle in one process so every transition
# is verified against the preceding state rather than a stubbed callback.
prepare_m10_add_fixture
initial_hash=$(sha256sum "$WG_CONF")
baseline_backup=$(create_backup "$WG_CONF" m11 "$WG_INTERFACE")
baseline_id=${baseline_backup##*/}
set +e; add_client m11-client >/dev/null 2>"$TEST_ROOT/m11-lifecycle-add.err"; status=$?; set -e
assert_equal 0 "$status" 'lifecycle add succeeds'
assert_file_exists "$CLIENT_DIR/m11-client.conf" 'lifecycle client config published'
assert_file_exists "$METADATA_DIR/m11-client.meta" 'lifecycle metadata published'
assert_equal 600 "$(stat -c '%a' "$CLIENT_DIR/m11-client.conf")" 'lifecycle config mode'
assert_equal 600 "$(stat -c '%a' "$METADATA_DIR/m11-client.meta")" 'lifecycle metadata mode'
assert_contains '192.0.2.3/32' "$(<"$WG_CONF")" 'lifecycle server peer address'
assert_contains 'operation=add' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle add audit'
# Both the original fixture peer and the newly managed peer are present;
# assert their independent origins before removing the managed one.
m11_dump="$TEST_ROOT/m11-after-add.dump"
cat >"$m11_dump" <<'EOF'
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= 51820 0
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 0 0 0 0
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= (none) vpn.example.invalid:51820 192.0.2.3/32 0 0 0 0
EOF
export WG_MOCK_DUMP_FILE="$m11_dump"; unset WG_MOCK_DUMP_SEQUENCE_DIR
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e
assert_equal 0 "$status" 'lifecycle mixed model is readable'
assert_contains $'\tmanaged' "$output" 'lifecycle managed origin'
assert_contains $'\tunmanaged' "$output" 'lifecycle unmanaged origin'
assert_contains '192.0.2.3/32' "$output" 'lifecycle managed address is distinct'
assert_contains '192.0.2.2/32' "$output" 'lifecycle unmanaged address is distinct'
# The runtime state after removal is the baseline (the managed peer is
# absent); keep this separate from the add-state dump used for listing.
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$TEST_ROOT/m11-after-remove.dump"
export WG_MOCK_DUMP_FILE="$TEST_ROOT/m11-after-remove.dump"
# Remove by the authoritative public key, then verify the exact source
# bytes and archive rather than merely checking a return status.
set +e; output=$(remove_client_exact m11-client FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= 2>"$TEST_ROOT/m11-lifecycle-remove.err"); status=$?; set -e
assert_equal 0 "$status" 'lifecycle remove succeeds'
assert_equal 0 "$(grep -c '192.0.2.3/32' "$WG_CONF" || true)" 'lifecycle remove removes managed peer address'
assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -type f -name 'm11-client.*' | wc -l)" 'lifecycle remove deletes source artifacts'
assert_equal 1 "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'lifecycle remove creates one archive'
assert_contains 'operation=remove' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle remove audit'
# Restore the pre-add snapshot through the real restore backend. The
# runtime fixture is the baseline dump, so verification also covers the
# current-state backup created by restore.
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"
set +e; output=$(restore_backup "$baseline_id" --yes 2>"$TEST_ROOT/m11-lifecycle-restore.err"); status=$?; set -e
assert_equal 0 "$status" 'lifecycle restore succeeds'
assert_equal "$initial_hash" "$(sha256sum "$WG_CONF")" 'lifecycle restore is byte-identical to baseline'
assert_equal 1 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "$baseline_id" | wc -l)" 'lifecycle source backup remains valid'
assert_contains 'operation=restore' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle restore audit'
assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'lifecycle restore applies runtime state'
begin_test 'M11 real add rollback and failed rollback are distinct'
prepare_m10_add_fixture
failed_hash=$(sha256sum "$WG_CONF")
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m11-sync-fail"
set +e; add_client m11-failed >/dev/null 2>"$TEST_ROOT/m11-failed.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'lifecycle apply failure rolls back successfully'
assert_equal "$failed_hash" "$(sha256sum "$WG_CONF")" 'successful rollback preserves disk'
assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -type f -name 'm11-failed.*' | wc -l)" 'successful rollback removes client artifacts'
assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'successful rollback is audited'
prepare_m10_add_fixture
failed_hash=$(sha256sum "$WG_CONF")
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m11-critical-sync" TRANSACTION_ROLLBACK_REPLACE_FAIL=1
set +e; add_client m11-critical >/dev/null 2>"$TEST_ROOT/m11-critical.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL
assert_equal "$EXIT_ROLLBACK" "$status" 'failed rollback is critical'
assert_equal 1 "$(test -f "$WG_CONF" && printf 1 || printf 0)" 'failed rollback keeps a live source file'
assert_contains 'КРИТИЧНО' "$(<"$TEST_ROOT/m11-critical.err")" 'failed rollback diagnostic is critical'
begin_test 'M11 diagnostics skip unavailable optional capabilities'
prepare_m10_add_fixture
printf '1\n' >"$TEST_ROOT/m11-ipv4-forward"
printf '1\n' >"$TEST_ROOT/m11-ipv6-forward"
export WG_MANAGER_IPV4_FORWARD_FILE="$TEST_ROOT/m11-ipv4-forward" WG_MANAGER_IPV6_FORWARD_FILE="$TEST_ROOT/m11-ipv6-forward" DIAG_DISABLE_COMMANDS='systemctl,journalctl,nft,iptables,ss'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
unset DIAG_DISABLE_COMMANDS
assert_equal 0 "$status" 'optional diagnostic capabilities degrade safely'
assert_contains 'systemd=SKIP' "$output" 'systemd unavailable is skipped'
assert_contains 'journal=SKIP' "$output" 'journal unavailable is skipped'
assert_contains 'udp-listener=SKIP' "$output" 'ss unavailable is skipped'
assert_contains 'firewall=SKIP' "$output" 'firewall unavailable is skipped'
begin_test 'M11 malformed metadata is inert for read-only inspection'
printf 'UNKNOWN=value\n' >"$METADATA_DIR/bad.meta"; chmod 600 -- "$METADATA_DIR/bad.meta"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e
assert_failure "$status" 'malformed metadata fails closed'
assert_equal 0 "$(grep -c 'UNKNOWN=value' "$TEST_MOCK_LOG" || true)" 'metadata text never reaches mock argv'
;;
secret-scan)
begin_test 'M11 repository and lifecycle secret scan'
local repository_hits lifecycle_output secret_token secret_tokens forbidden_endpoint
local forbidden_host_path="/$(printf 'etc')/$(printf 'wireguard')"
repository_hits=$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' --glob 'README.md' "37\\.26\\.136\\.232|${forbidden_host_path}" "$PROJECT_DIR" || true)
assert_equal '' "$repository_hits" 'production/docs contain no host endpoint or live WireGuard path'
local production_keys
production_keys=$(rg -o --glob 'wg-manager.sh' --glob 'add-user.sh' --glob 'README.md' '[A-Za-z0-9+/]{43}=' "$PROJECT_DIR" || true)
assert_equal '' "$production_keys" 'production and docs contain no key-shaped literals'
secret_tokens=$(rg -o --glob '!/.git/**' --glob '!*.md' '[A-Za-z0-9+/]{43}=' "$PROJECT_DIR" | sed 's/.*://' | sort -u || true)
local unexpected_token=0
while IFS= read -r secret_token; do
[[ -z $secret_token ]] && continue
case $secret_token in
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=|DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=|EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=|FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=|GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=|HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=) ;;
*) unexpected_token=1 ;;
esac
done <<<"$secret_tokens"
assert_equal 0 "$unexpected_token" 'repository key-shaped values are documented placeholders only'
forbidden_endpoint=$(rg -n '37\\.26\\.136\\.232' "$PROJECT_DIR/wg-manager.sh" "$PROJECT_DIR/add-user.sh" "$PROJECT_DIR/README.md" "$PROJECT_DIR/tests/fixtures" || true)
assert_equal '' "$forbidden_endpoint" 'repository contains no removed public endpoint'
lifecycle_output=$(bash "$PROJECT_DIR/wg-manager.sh" help 2>&1)
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|[A-Za-z0-9+/]{43}=' <<<"$lifecycle_output" || true)" 'captured CLI output has no key material'
assert_equal '' "$(grep -E "37\\.26\\.136\\.232|${forbidden_host_path}" "$TEST_MOCK_LOG" || true)" 'mock log has no host paths'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'mock log has no secret markers'
begin_test 'M11 captured real lifecycle output is secret-free'
prepare_m10_add_fixture
set +e; lifecycle_output=$(add_client secret-scan 2>"$TEST_ROOT/secret-add.err"); status=$?; set -e
assert_equal 0 "$status" 'secret scan real add succeeds'
set +e; lifecycle_output+=$'\n'"$(show_client secret-scan 2>"$TEST_ROOT/secret-show.err")"; status=$?; set -e
assert_equal 0 "$status" 'secret scan real show succeeds'
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR WG_SYNC_FAIL_ONCE_FILE
set +e; add_client secret-failed >/dev/null 2>"$TEST_ROOT/secret-fail.err"; status=$?; set -e
local captured_secret_stream
captured_secret_stream=$(printf '%s\n' "$lifecycle_output" "$(<"$TEST_ROOT/secret-add.err")" "$(<"$TEST_ROOT/secret-show.err")" "$(<"$TEST_ROOT/secret-fail.err")" "$(<"$LOG_DIR/wg-manager.log")" "$(<"$TEST_MOCK_LOG")")
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]|\[Peer\]' <<<"$captured_secret_stream" || true)" 'ordinary lifecycle output contains no secret/config markers'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$LOG_DIR/wg-manager.log" "$TEST_MOCK_LOG" || true)" 'lifecycle audit and mocks contain no private/PSK values'
;;
esac
}
if [[ $test_group == symlink-safety || $test_group == no-host-writes || $test_group == no-host-command-bypass || $test_group == m11-e2e || $test_group == secret-scan ]]; then
run_m11_tests
elif [[ $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
run_m5_tests
@@ -4126,6 +4463,11 @@ else
run_m9_tests
done
unset m9_group
for m11_group in symlink-safety no-host-writes no-host-command-bypass m11-e2e secret-scan; do
test_group=$m11_group
run_m11_tests
done
unset m11_group
test_group=''
fi