feat(tui): add safe client and backup workflows

This commit is contained in:
2026-10-14 22:49:31 +03:00
parent 8b40b8cd65
commit 06c7171e91
5 changed files with 925 additions and 46 deletions
+15 -1
View File
@@ -270,7 +270,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
| M7 | DONE | Diagnostics engine and report | | M7 | DONE | Diagnostics engine and report |
| M8 | DONE | Backup listing/restoration and audit log UX | | M8 | DONE | Backup listing/restoration and audit log UX |
| M9 | DONE | TUI foundation, dashboard, and read-only screens | | M9 | DONE | TUI foundation, dashboard, and read-only screens |
| M10 | NOT STARTED | Complete TUI mutation workflows | | M10 | DONE | Complete TUI mutation workflows |
| M11 | NOT STARTED | Hardening, documentation, and MVP acceptance | | M11 | NOT STARTED | Hardening, documentation, and MVP acceptance |
--- ---
@@ -1691,6 +1691,20 @@ Use this template:
- Suggested commit message: `feat(tui): add gum dashboard and read-only workflows` - Suggested commit message: `feat(tui): add gum dashboard and read-only workflows`
- Next milestone: M10 - Next milestone: M10
## 2026-10-14 — M10 — DONE
- Implemented: shared-backend `gum` workflows for full- and split-tunnel client creation, exact managed-client actions and archival removal, explicit config and QR confirmation, manual backup, validated deliberate restore, bounded backend failures, and immediate nonzero exit after critical rollback failure; added custom split-network CLI support needed by the TUI.
- Coordinator: senior Sol coordinator
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
- Repair cycles: 15
- Sol review: replaced aliased and stubbed M10 coverage with real isolated backend scenarios; fixed exact-identity revalidation, archive-result transport from transaction subshells, QR status loss and missing-dependency fallback, overlong destructive prompts, invalid failed-audit categories, fixture runtime sequencing and cross-group contamination, secret-bearing output boundaries, and critical rollback propagation; every material finding received regression coverage.
- Verification: `bash -n wg-manager.sh tests/run.sh tests/mocks/bin/gum`, `bash tests/run.sh tui-add`, `bash tests/run.sh tui-remove`, `bash tests/run.sh tui-client-actions`, `bash tests/run.sh tui-backup`, `bash tests/run.sh tui-restore`, `bash tests/run.sh tui-backend-failure`, `bash tests/run.sh tui-no-secret-args`, `bash tests/run.sh remove-rollback`, `bash tests/run.sh restore-rollback`, `bash tests/run.sh`, `git diff --check`, full changed-file, permission, rollback, audit, and secret-pattern review.
- Result: all required checks passed; tui-add/tui-remove/tui-client-actions/tui-backup/tui-restore/tui-backend-failure/tui-no-secret-args passed 52/40/28/6/20/9/11 assertions, and the full regression suite passed 947 assertions. Optional ShellCheck and real-`gum` interactive smoke were `SKIP` because neither ShellCheck nor a usable production `gum` environment is available.
- Decisions: none
- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, and production terminal state were not accessed; M10 verification is fixture- and mock-based as required.
- Suggested commit message: `feat(tui): add safe client and backup workflows`
- Next milestone: M11
# 8. Final completion rule # 8. Final completion rule
The MVP is complete only when: The MVP is complete only when:
+12 -6
View File
@@ -50,7 +50,8 @@ not inspect or modify host WireGuard state.
## M4 client creation ## M4 client creation
`add NAME` creates a client transactionally. The optional forms are `add NAME` creates a client transactionally. The optional forms are
`--mode full-tunnel|split-tunnel` and `--endpoint HOST`; unknown or extra `--mode full-tunnel|split-tunnel`, optional `--split-networks CIDRS` for
split-tunnel, and `--endpoint HOST`; unknown or extra
arguments are rejected. Names are restricted to ASCII letters, digits, `.`, arguments are rejected. Names are restricted to ASCII letters, digits, `.`,
`_`, and `-` (without a leading `-`). Root is required for production use; `_`, and `-` (without a leading `-`). Root is required for production use;
tests enable only an isolated `WG_MANAGER_TEST_MODE` under `TEST_ROOT`. tests enable only an isolated `WG_MANAGER_TEST_MODE` under `TEST_ROOT`.
@@ -84,17 +85,22 @@ 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.
## M9 TUI ## M9–M10 TUI
Запуск без аргументов или командой `tui` открывает read-only интерфейс `gum` в Запуск без аргументов или командой `tui` открывает интерфейс `gum` в
терминале. `gum` разрешается в порядке `WG_MANAGER_GUM`, локального `bin/gum`, терминале. `gum` разрешается в порядке `WG_MANAGER_GUM`, локального `bin/gum`,
затем `PATH`; необходимые подкоманды проверяются до вывода интерфейса. Нужны затем `PATH`; необходимые подкоманды проверяются до вывода интерфейса. Нужны
одновременно интерактивные stdin и stdout. Escape и отмена возвращают на одновременно интерактивные stdin и stdout. Escape и отмена возвращают на
предыдущий экран или завершают меню без изменений. Доступны status, clients/show, предыдущий экран или завершают меню без изменений. Доступны status, clients/show,
diagnose, log и backups; add/remove в M9 являются placeholder-действиями и не diagnose, log и backups. В M10 также доступны add/remove, создание backup и
выполняют мутаций. CLI работает независимо от `gum`; отсутствие или неполная restore через те же backend-функции, что и CLI; подтверждение и отмена остаются
обязательными для мутаций, а критический rollback завершает TUI с ненулевым кодом.
Для клиента сначала показываются безопасные детали; конфигурация и QR требуют
отдельного явного действия, при отсутствии `qrencode` показывается понятный
fallback. CLI работает независимо от `gum`; отсутствие или неполная
возможность `gum` даёт код `69` только для TUI, а неинтерактивный запуск TUI — возможность `gum` даёт код `69` только для TUI, а неинтерактивный запуск TUI —
код `64`. код `64`. Восстановление требует ввода полного идентификатора выбранной копии;
несовпадение или отмена не изменяют состояние.
## M6 удаление клиента ## M6 удаление клиента
+1 -1
View File
@@ -42,7 +42,7 @@ case $subcommand in
fi fi
case $subcommand in case $subcommand in
confirm) [[ $answer == y || $answer == Y ]] || exit 1; exit 0 ;; confirm) [[ $answer == y || $answer == Y ]] || exit 1; exit 0 ;;
input) printf '%s\n' "$answer"; exit 0 ;; input) [[ -n $answer ]] || exit 1; printf '%s\n' "$answer"; exit 0 ;;
*) [[ -n $answer ]] && printf '%s\n' "$answer" || exit 1; exit 0 ;; *) [[ -n $answer ]] && printf '%s\n' "$answer" || exit 1; exit 0 ;;
esac esac
;; ;;
+603 -12
View File
@@ -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|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) ;; *) 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) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
esac esac
test_group=${1-} test_group=${1-}
@@ -391,6 +391,10 @@ run_gum_tests() {
prepare_m9_fixture() { prepare_m9_fixture() {
local config_file="${TEST_ROOT}/m9.conf" fixture_error local config_file="${TEST_ROOT}/m9.conf" fixture_error
# Each focused/full-suite invocation shares TEST_ROOT; own every fixture
# directory before seeding it so backup counts and captures are deterministic.
rm -rf -- "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive"
rm -f -- "${TEST_ROOT}/manager.lock" "${TEST_ROOT}/lock" "${TEST_ROOT}/gum-script" "${TEST_ROOT}/gum-style.txt" "${TEST_ROOT}/gum-pager.txt" "${TEST_ROOT}/gum-choices.txt"
mkdir -p -- "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" mkdir -p -- "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs"
cp -- "${PROJECT_DIR}/tests/fixtures/wg0.conf" "${TEST_ROOT}/wg0.conf" cp -- "${PROJECT_DIR}/tests/fixtures/wg0.conf" "${TEST_ROOT}/wg0.conf"
write_valid_config "$config_file" write_valid_config "$config_file"
@@ -413,8 +417,84 @@ prepare_m9_fixture() {
: >"$TEST_MOCK_LOG" : >"$TEST_MOCK_LOG"
} }
prepare_m10_add_fixture() {
local config_file="${TEST_ROOT}/m10-add.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump"
rm -rf -- "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/metadata" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/client-files"
rm -f -- "${TEST_ROOT}/lock" "${TEST_ROOT}/manager.lock" "${TEST_ROOT}/gum-script" "${TEST_ROOT}/gum-style.txt" "${TEST_ROOT}/gum-pager.txt" "${TEST_ROOT}/gum-choices.txt"
cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"
write_m2_config "$config_file" "$fixture"
export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both WG_MOCK_INTERFACE=wg0 GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt"
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_file"
rm -rf -- "${TEST_ROOT}/dump-sequence"
mkdir -p -- "${TEST_ROOT}/dump-sequence"
cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/2"
cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/3"
cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/4"
cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/5"
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
set_runtime_paths
load_config
rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"
mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive"
: >"$TEST_MOCK_LOG"
}
prepare_m10_remove_fixture() {
local config_file="${TEST_ROOT}/m10-remove.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_dir="${TEST_ROOT}/remove-dumps"
rm -rf -- "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/metadata" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/client-files" "${runtime_dir}"
rm -f -- "${TEST_ROOT}/lock" "${TEST_ROOT}/manager.lock" "${TEST_ROOT}/gum-script" "${TEST_ROOT}/gum-style.txt" "${TEST_ROOT}/gum-pager.txt" "${TEST_ROOT}/gum-choices.txt"
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}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both WG_MOCK_INTERFACE=wg0 GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt"
set_runtime_paths
load_config
rm -rf -- "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" "$LOG_DIR" "$runtime_dir" "$BACKUP_DIR"
mkdir -p -- "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" "$LOG_DIR" "$runtime_dir"
chmod 700 -- "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR" "$LOG_DIR" "$runtime_dir"
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 -- "$METADATA_DIR/alice.meta" "$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"
cp -- "$runtime_dir/1" "$runtime_dir/2"
cp -- "$runtime_dir/1" "$runtime_dir/3"
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\n' >"$runtime_dir/4"
cp -- "$runtime_dir/1" "$runtime_dir/5"
export WG_MOCK_DUMP_SEQUENCE_DIR="$runtime_dir" WG_MOCK_DUMP_FILE="$runtime_dir/1" TEST_MOCK_LOG="${TEST_ROOT}/mocks.log"
unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL M6_ARCHIVE_COPY_FAIL_INDEX M6_ARCHIVE_REMOVE_FAIL_INDEX M6_ARCHIVE_RECOVERY_FAIL_INDEX
: >"$TEST_MOCK_LOG"
}
prepare_m10_restore_fixture() {
local config_file="${TEST_ROOT}/m10-restore.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/restore-runtime.dump"
rm -rf -- "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/metadata" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/client-files"
rm -f -- "${TEST_ROOT}/lock" "${TEST_ROOT}/manager.lock" "${TEST_ROOT}/gum-script" "${TEST_ROOT}/gum-style.txt" "${TEST_ROOT}/gum-pager.txt" "${TEST_ROOT}/gum-choices.txt"
cp -- "${PROJECT_DIR}/tests/fixtures/m3/source.conf" "$fixture"
chmod 600 -- "$fixture"
write_m2_config "$config_file" "$fixture"
export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both WG_MOCK_INTERFACE=wg0 GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt"
set_runtime_paths
load_config
rm -rf -- "$BACKUP_DIR" "$LOG_DIR" "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR"
mkdir -p -- "$BACKUP_DIR" "$LOG_DIR" "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR"
chmod 700 -- "$BACKUP_DIR" "$LOG_DIR" "$METADATA_DIR" "$CLIENT_DIR" "$ARCHIVE_DIR"
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$runtime_file"
export WG_MOCK_DUMP_FILE="$runtime_file"
unset WG_MOCK_DUMP_SEQUENCE_DIR WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL TRANSACTION_ROLLBACK_REPLACE_FAIL
: >"$TEST_MOCK_LOG"
}
run_m9_tests() { run_m9_tests() {
local output status script_file tty_mode pager_count snapshot_before lock_status long_name local output status script_file tty_mode pager_count snapshot_before lock_status long_name before_backup secret_stream add_output captured_prompt formatted_prompt long_endpoint
case $test_group in case $test_group in
gum-resolution) gum-resolution)
run_gum_tests run_gum_tests
@@ -463,13 +543,13 @@ run_m9_tests() {
printf 'Неизвестно\nНеизвестно\nНеизвестно\n' >"$script_file" printf 'Неизвестно\nНеизвестно\nНеизвестно\n' >"$script_file"
set +e; tui_screen >/dev/null 2>&1; status=$?; set -e set +e; tui_screen >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_OPERATION" "$status" 'three route failures are bounded' assert_equal "$EXIT_OPERATION" "$status" 'three route failures are bounded'
printf 'Диагностика\nЖурнал\nРезервные копии\nДобавить\nУдалить\nВыход\n' >"$script_file" printf 'Диагностика\nЖурнал\nРезервные копии\nОтмена\nДобавить\n\nУдалить\n\nВыход\n' >"$script_file"
: >"$TEST_MOCK_LOG" : >"$TEST_MOCK_LOG"
set +e; output=$(tui_screen 2>&1); status=$?; set -e set +e; output=$(tui_screen 2>&1); status=$?; set -e
assert_equal 0 "$status" 'all read-only and placeholder routes return to menu' assert_equal 0 "$status" 'all read-only and placeholder routes return to menu'
assert_contains 'Действие «Добавить»' "$output" 'add placeholder is visible' assert_contains 'gum input' "$(<"$TEST_MOCK_LOG")" 'add route reaches input wrapper'
assert_contains 'Действие «Удалить»' "$output" 'remove placeholder is visible' assert_contains 'gum filter' "$(<"$TEST_MOCK_LOG")" 'remove route reaches filter wrapper'
assert_equal 0 "$(grep -c '^gum pager$' "$TEST_MOCK_LOG" | awk '$1 >= 3 { print 0; next } { print 1 }')" 'diagnose/log/backups reach pager' assert_equal 0 "$(grep -c '^gum pager$' "$TEST_MOCK_LOG" | awk '$1 >= 2 { print 0; next } { print 1 }')" 'diagnose and log reach pager'
printf 'Выход\n' >"$script_file" printf 'Выход\n' >"$script_file"
set +e; main >/dev/null 2>&1; status=$?; set -e set +e; main >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'zero-argument main enters TUI' assert_equal 0 "$status" 'zero-argument main enters TUI'
@@ -498,9 +578,9 @@ run_m9_tests() {
set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e
assert_failure "$status" 'unset test TTY is not accepted' assert_failure "$status" 'unset test TTY is not accepted'
;; ;;
tui-read-only) tui-read-only)
prepare_m9_fixture prepare_m9_fixture
script_file="${TEST_ROOT}/gum-script"; printf 'Клиенты\nalice — BBBBBBBB\nВыход\n' >"$script_file"; export GUM_SCRIPT_FILE="$script_file" script_file="${TEST_ROOT}/gum-script"; printf 'Клиенты\nalice — BBBBBBBB\nДетали\nВыход\n' >"$script_file"; export GUM_SCRIPT_FILE="$script_file"
snapshot_before=$(find "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" -mindepth 1 -maxdepth 2 -printf '%P %y %m %s\n' | LC_ALL=C sort; sha256sum "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta"; stat -c '%a:%u:%g:%h' "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta") snapshot_before=$(find "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" -mindepth 1 -maxdepth 2 -printf '%P %y %m %s\n' | LC_ALL=C sort; sha256sum "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta"; stat -c '%a:%u:%g:%h' "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta")
begin_test 'TUI client selection uses stable identity and read-only backend' begin_test 'TUI client selection uses stable identity and read-only backend'
set +e; tui_screen >/dev/null 2>&1; status=$?; set -e set +e; tui_screen >/dev/null 2>&1; status=$?; set -e
@@ -511,7 +591,7 @@ run_m9_tests() {
assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)' "$TEST_MOCK_LOG" || true)" 'read-only screen has no mutation calls' assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)' "$TEST_MOCK_LOG" || true)" 'read-only screen has no mutation calls'
CLIENT_NAMES=(same same) CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=) CLIENT_NAMES=(same same) CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=)
CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32) CLIENT_ENDPOINTS=(- -) CLIENT_HANDSHAKES=(0 0) CLIENT_RX=(0 0) CLIENT_TX=(0 0) CLIENT_STATES=(unmanaged unmanaged) CLIENT_ORIGINS=(unmanaged unmanaged) CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32) CLIENT_ENDPOINTS=(- -) CLIENT_HANDSHAKES=(0 0) CLIENT_RX=(0 0) CLIENT_TX=(0 0) CLIENT_STATES=(unmanaged unmanaged) CLIENT_ORIGINS=(unmanaged unmanaged)
printf 'same — ABCDEFGH #2\n' >"$script_file" printf 'same — ABCDEFGH #2\nДетали\n' >"$script_file"
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'duplicate display names select the offered stable index' assert_equal 0 "$status" 'duplicate display names select the offered stable index'
assert_contains 'ABCDEFGH222222222222222222222222222222222=' "$(<"$GUM_PAGER_CAPTURE")" 'selected duplicate identity renders the second full key' assert_contains 'ABCDEFGH222222222222222222222222222222222=' "$(<"$GUM_PAGER_CAPTURE")" 'selected duplicate identity renders the second full key'
@@ -519,7 +599,7 @@ run_m9_tests() {
long_name=$(printf 'n%.0s' {1..64}) long_name=$(printf 'n%.0s' {1..64})
CLIENT_NAMES=("$long_name" "$long_name") CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=) CLIENT_NAMES=("$long_name" "$long_name") CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=)
CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32) CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32)
printf '%s\n' "${long_name:0:60} — ABCDEFGH #2" >"$script_file" printf '%s\nДетали\n' "${long_name:0:60} — ABCDEFGH #2" >"$script_file"
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'long client names remain selectable' assert_equal 0 "$status" 'long client names remain selectable'
assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' "$GUM_CHOICES_CAPTURE")" 'offered client labels fit 80 columns' assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' "$GUM_CHOICES_CAPTURE")" 'offered client labels fit 80 columns'
@@ -531,6 +611,514 @@ run_m9_tests() {
assert_equal 0 "$lock_status" 'read-only screens do not create a mutation lock' assert_equal 0 "$lock_status" 'read-only screens do not create a mutation lock'
assert_equal 0 "$(grep -hE 'AAAAAAAAAAAAAAAA|PresharedKey' "$GUM_PAGER_CAPTURE" "$GUM_STYLE_CAPTURE" "$TEST_MOCK_LOG" | wc -l)" 'read-only TUI output has no secrets' assert_equal 0 "$(grep -hE 'AAAAAAAAAAAAAAAA|PresharedKey' "$GUM_PAGER_CAPTURE" "$GUM_STYLE_CAPTURE" "$TEST_MOCK_LOG" | wc -l)" 'read-only TUI output has no secrets'
;; ;;
tui-add)
prepare_m10_add_fixture
begin_test 'TUI add real full-tunnel success uses the shared backend'
printf 'alice\nПолный туннель\ny\ny\nn\n' >"${TEST_ROOT}/gum-script"
export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_add_flow 2>"${TEST_ROOT}/add.err"); status=$?; set -e
assert_equal 0 "$status" 'real TUI add succeeds'
assert_file_exists "${CLIENT_DIR}/alice.conf" 'real client config exists'
assert_file_exists "${METADATA_DIR}/alice.meta" 'real client metadata exists'
assert_file_mode 600 "${CLIENT_DIR}/alice.conf" 'real client config mode'
assert_file_mode 600 "${METADATA_DIR}/alice.meta" 'real metadata mode'
assert_contains 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' "$(<"$WG_CONF")" 'generated peer is in server config'
assert_contains '192.0.2.3/32' "$(<"$WG_CONF")" 'generated address is in server config'
assert_contains 'AllowedIPs = 0.0.0.0/0,::/0' "$(<"${CLIENT_DIR}/alice.conf")" 'full-tunnel AllowedIPs are generated'
assert_contains 'Endpoint = vpn.example.invalid:51820' "$(<"${CLIENT_DIR}/alice.conf")" 'configured endpoint is generated'
assert_contains 'wg syncconf' "$(<"$TEST_MOCK_LOG")" 'runtime syncconf is invoked'
assert_contains 'operation=add' "$(<"${LOG_DIR}/wg-manager.log")" 'add audit is recorded'
assert_contains "${CLIENT_DIR}/alice.conf" "$output" 'final config path is shown'
assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_MOCK_LOG" "${LOG_DIR}/wg-manager.log" "${TEST_ROOT}/add.err" "$GUM_PAGER_CAPTURE" || true)" 'add outputs and logs are secret-free'
begin_test 'TUI add preserves QR operation failure status'
prepare_m10_add_fixture
export QR_MOCK_EXPECTED_FILE="${CLIENT_DIR}/qr-fail.conf" QR_MOCK_FAIL=1
printf 'qr-fail\nПолный туннель\ny\ny\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/qr-fail-add.err"; status=$?; set -e
unset QR_MOCK_EXPECTED_FILE QR_MOCK_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'TUI add returns QR operation failure'
assert_contains 'QR-код недоступен' "$(<"${TEST_ROOT}/qr-fail-add.err")" 'TUI add preserves QR diagnostic'
begin_test 'TUI add treats missing QR dependency as a normal fallback'
prepare_m10_add_fixture
export QR_MOCK_EXPECTED_FILE="${CLIENT_DIR}/qr-missing.conf"
saved_path=$PATH
mkdir -- "${TEST_ROOT}/qr-add-no-command"
for utility in bash env wg wg-quick cat stat sort paste date tail mv cp chmod mkdir mktemp flock rm ln; do
ln -s -- "$(command -v "$utility")" "${TEST_ROOT}/qr-add-no-command/$utility"
done
PATH="${TEST_ROOT}/qr-add-no-command"
printf 'qr-missing\nПолный туннель\ny\ny\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_add_flow 2>"${TEST_ROOT}/qr-missing-add.err"); status=$?; set -e
PATH=$saved_path
unset QR_MOCK_EXPECTED_FILE
assert_equal 0 "$status" 'missing QR dependency is a normal TUI add fallback'
assert_contains 'qr-missing.conf' "$output" 'missing QR fallback retains config path'
assert_contains 'QR-код недоступен' "$(<"${TEST_ROOT}/qr-missing-add.err")" 'missing QR fallback is actionable'
begin_test 'TUI add split tunnel accepts normalized networks and endpoint override'
prepare_m10_add_fixture
printf 'bob\nВыборочные сети\n10.40.0.0/16, 2001:db8:40::/48\nn\nalt.example.invalid\ny\nn\n' >"${TEST_ROOT}/gum-script"
export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_add_flow 2>"${TEST_ROOT}/split.err"); status=$?; set -e
assert_equal 0 "$status" 'split TUI add succeeds'
assert_file_exists "${CLIENT_DIR}/bob.conf" 'split client config exists'
assert_contains 'AllowedIPs = 10.40.0.0/16,2001:db8:40::/48' "$(<"${CLIENT_DIR}/bob.conf")" 'split networks are normalized'
assert_contains 'Endpoint = alt.example.invalid:51820' "$(<"${CLIENT_DIR}/bob.conf")" 'endpoint override is applied'
assert_contains 'MODE=split-tunnel' "$(<"${METADATA_DIR}/bob.meta")" 'split mode is recorded'
assert_contains 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=' "$(<"$WG_CONF")" 'split peer reaches server config'
assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_MOCK_LOG" "${LOG_DIR}/wg-manager.log" "${TEST_ROOT}/split.err" "$GUM_PAGER_CAPTURE" || true)" 'split output and logs are secret-free'
begin_test 'TUI add cancellation before create preserves isolated state'
prepare_m10_add_fixture
before_hash=$(sha256sum "$WG_CONF")
printf 'alice\nПолный туннель\ny\nn\n' >"${TEST_ROOT}/gum-script"
export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/cancel.err"; status=$?; set -e
assert_equal 0 "$status" 'create cancellation is normal'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'create cancellation preserves server bytes'
assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -mindepth 1 -maxdepth 1 -type f | wc -l)" 'create cancellation leaves no artifacts'
assert_equal '' "$(grep -E 'syncconf|wg-quick' "$TEST_MOCK_LOG" || true)" 'create cancellation does not mutate runtime'
begin_test 'TUI add cancellation before name is harmless'
prepare_m10_add_fixture
before_hash=$(sha256sum "$WG_CONF")
: >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/empty-name.err"; status=$?; set -e
assert_equal 0 "$status" 'initial name cancellation is normal'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'initial cancellation preserves server bytes'
assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -mindepth 1 -maxdepth 1 -type f | wc -l)" 'initial cancellation leaves no artifacts'
begin_test 'TUI add corrects invalid name and endpoint'
prepare_m10_add_fixture
printf 'bad name\ncarol\nПолный туннель\nn\nbad endpoint\ncarol.example.invalid\ny\nn\n' >"${TEST_ROOT}/gum-script"
export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_add_flow 2>"${TEST_ROOT}/correction.err"); status=$?; set -e
assert_equal 0 "$status" 'invalid name and endpoint can be corrected'
assert_file_exists "${CLIENT_DIR}/carol.conf" 'corrected client artifact exists'
assert_contains 'carol.example.invalid:51820' "$(<"${CLIENT_DIR}/carol.conf")" 'corrected endpoint is used'
assert_contains 'Некорректное имя клиента' "$(<"${TEST_ROOT}/correction.err")" 'invalid name gives actionable feedback'
assert_contains 'Некорректный endpoint' "$(<"${TEST_ROOT}/correction.err")" 'invalid endpoint gives actionable feedback'
begin_test 'TUI add rejects invalid split networks before mutation'
prepare_m10_add_fixture
before_hash=$(sha256sum "$WG_CONF")
printf 'badnet\nВыборочные сети\nnot-a-cidr\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/bad-net.err"; status=$?; set -e
assert_equal "$EXIT_VALIDATION" "$status" 'invalid split network status'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'invalid split network preserves server bytes'
assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -mindepth 1 -maxdepth 1 -type f | wc -l)" 'invalid split network leaves no artifacts'
assert_equal '' "$(grep -E 'syncconf|wg-quick' "$TEST_MOCK_LOG" || true)" 'invalid split network does not mutate runtime'
begin_test 'TUI add apply failure rolls back and removes staged artifacts'
prepare_m10_add_fixture
before_hash=$(sha256sum "$WG_CONF")
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-fail"
unset WG_MOCK_DUMP_SEQUENCE_DIR
printf 'dana\nПолный туннель\ny\ny\nn\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_add_flow >/dev/null 2>"${TEST_ROOT}/apply-fail.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'apply failure returns operation status after rollback'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'apply failure restores server bytes'
assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -type f -name 'dana.*' | wc -l)" 'apply failure leaves no client artifacts'
assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'apply failure attempted runtime update and rollback'
assert_contains 'operation=add' "$(<"${LOG_DIR}/wg-manager.log")" 'apply failure is audited'
begin_test 'CLI add split-networks contract is validated'
prepare_m10_add_fixture
set +e; add_client cli --mode split-tunnel --split-networks '10.40.0.0/16,2001:db8:40::/48' >/dev/null 2>"${TEST_ROOT}/cli-split.err"; status=$?; set -e
assert_equal 0 "$status" 'CLI split add succeeds'
assert_contains 'AllowedIPs = 10.40.0.0/16,2001:db8:40::/48' "$(<"${CLIENT_DIR}/cli.conf")" 'CLI split networks reach config'
prepare_m10_add_fixture
set +e; add_client missing --mode split-tunnel --split-networks >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'missing split-networks value is usage error'
prepare_m10_add_fixture
set +e; add_client repeat --mode split-tunnel --split-networks '10.40.0.0/16' --split-networks '10.41.0.0/16' >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'repeated split-networks is usage error'
prepare_m10_add_fixture
set +e; add_client invalid --mode split-tunnel --split-networks 'not-a-cidr' >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_VALIDATION" "$status" 'invalid split CIDR is validation error'
prepare_m10_add_fixture
set +e; add_client full --mode full-tunnel --split-networks '10.40.0.0/16' >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_VALIDATION" "$status" 'split-networks is rejected for full tunnel'
;;
tui-remove)
prepare_m10_remove_fixture
load_client_model >/dev/null
begin_test 'TUI remove real success archives exact client and peer'
printf 'alice — 192.0.2.2/32 — BBBBBBBB\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_remove_flow 2>"${TEST_ROOT}/remove.err"); status=$?; set -e
output+=$'\n'"$(<"${TEST_ROOT}/remove.err")"
assert_equal 0 "$status" 'real TUI remove succeeds'
assert_equal 0 "$(grep -c 'PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$WG_CONF" || true)" 'removed peer is absent'
assert_file_absent "$CLIENT_DIR/alice.conf" 'client config is archived'
assert_file_absent "$METADATA_DIR/alice.meta" 'client metadata is archived'
archive_path=$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)
if [[ -n $archive_path ]]; then
assert_file_exists "$archive_path/alice.conf" 'archive contains config'
assert_file_exists "$archive_path/alice.meta" 'archive contains metadata'
assert_file_mode 700 "$archive_path" 'archive mode is restrictive'
assert_file_mode 600 "$archive_path/alice.conf" 'archived config mode'
else
assert_equal 1 0 'archive path is returned after successful removal'
fi
assert_contains 'Архив:' "$output" 'TUI reports actual archive path'
assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'remove applies runtime update'
assert_contains 'operation=remove' "$(<"$LOG_DIR/wg-manager.log")" 'remove audit is recorded'
assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "$TEST_ROOT/remove.err" || true)" 'remove output and logs are secret-free'
begin_test 'TUI remove cancellation at filter preserves state'
prepare_m10_remove_fixture
load_client_model >/dev/null
before_hash=$(sha256sum "$WG_CONF"); before_files=$(find "$CLIENT_DIR" "$METADATA_DIR" "$ARCHIVE_DIR" -type f -printf '%P %s\n' | LC_ALL=C sort)
: >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_remove_flow >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'filter cancellation is normal'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'filter cancellation preserves server'
assert_equal "$before_files" "$(find "$CLIENT_DIR" "$METADATA_DIR" "$ARCHIVE_DIR" -type f -printf '%P %s\n' | LC_ALL=C sort)" 'filter cancellation preserves artifacts'
assert_equal '' "$(grep -E 'syncconf|wg-quick' "$TEST_MOCK_LOG" || true)" 'filter cancellation does not mutate runtime'
begin_test 'TUI remove cancellation at confirmation preserves state'
prepare_m10_remove_fixture
load_client_model >/dev/null
before_hash=$(sha256sum "$WG_CONF")
printf 'alice — 192.0.2.2/32 — BBBBBBBB\nn\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_remove_flow >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'confirmation cancellation is normal'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'confirmation cancellation preserves server'
assert_equal 0 "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'confirmation cancellation creates no archive'
begin_test 'TUI remove excludes unmanaged clients and keeps labels bounded'
prepare_m10_remove_fixture
load_client_model >/dev/null
printf '%s\t%s\t-\t192.0.2.9/32\t0\t0\t0\t0\n' EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE= '(none)' >>"${TEST_ROOT}/remove-dumps/1"
printf '\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_remove_flow >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'unmanaged-only selection cancellation is normal'
assert_equal 0 "$(grep -c EEEEEEEE "$GUM_CHOICES_CAPTURE" || true)" 'unmanaged peer is not offered'
long_name=$(printf 'n%.0s' {1..64})
CLIENT_NAMES=("$long_name") CLIENT_KEYS=(BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=) CLIENT_ADDRESSES=(192.0.2.2/32) CLIENT_ORIGINS=(managed)
printf '\n' >"${TEST_ROOT}/gum-script"
set +e; tui_remove_flow >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' "$GUM_CHOICES_CAPTURE")" 'remove labels stay within 80 columns'
begin_test 'destructive TUI previews retain identity while fitting 80 columns'
captured_prompt=''
gum_confirm() { captured_prompt=$1; return 1; }
set +e; tui_remove_flow_for_index "$long_name" 0 >/dev/null 2>&1; status=$?; set -e
unset -f gum_confirm
gum_confirm() { gum_command confirm "$@"; }
assert_equal 0 "$status" 'long destructive preview cancellation is normal'
assert_contains "$long_name" "$captured_prompt" 'long name remains in destructive preview'
assert_contains '192.0.2.2/32' "$captured_prompt" 'address remains in destructive preview'
assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' <<<"$captured_prompt")" 'destructive preview lines fit 80 columns'
long_endpoint=$(printf 'e%.0s' {1..253})
formatted_prompt=$(tui_wrap_value 'Использовать endpoint:' "$long_endpoint")
assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' <<<"$formatted_prompt")" 'endpoint preview lines fit 80 columns'
begin_test 'TUI remove exact-key mismatch is rejected before mutation'
prepare_m10_remove_fixture
load_client_model >/dev/null
before_hash=$(sha256sum "$WG_CONF")
set +e; remove_client_exact alice CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC= >/dev/null 2>"${TEST_ROOT}/wrong-key.err"; status=$?; set -e
assert_equal "$EXIT_VALIDATION" "$status" 'wrong selected key is rejected'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'wrong selected key preserves server'
begin_test 'TUI remove apply failure rolls back disk and artifacts'
prepare_m10_remove_fixture
load_client_model >/dev/null
cp -- "$TEST_ROOT/remove-dumps/1" "$TEST_ROOT/remove-dumps/2"
cp -- "$TEST_ROOT/remove-dumps/1" "$TEST_ROOT/remove-dumps/3"
cp -- "$TEST_ROOT/remove-dumps/1" "$TEST_ROOT/remove-dumps/4"
cp -- "$TEST_ROOT/remove-dumps/1" "$TEST_ROOT/remove-dumps/5"
before_hash=$(sha256sum "$WG_CONF")
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/remove-sync-fail"
printf 'alice — 192.0.2.2/32 — BBBBBBBB\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_remove_flow >/dev/null 2>"${TEST_ROOT}/remove-fail.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'remove apply failure returns operation'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'remove apply failure restores server'
assert_file_exists "$CLIENT_DIR/alice.conf" 'remove apply failure preserves config'
assert_file_exists "$METADATA_DIR/alice.meta" 'remove apply failure preserves metadata'
assert_equal 0 "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'remove apply failure leaves no completed archive'
assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'remove rollback is audited'
assert_contains 'operation=remove target=alice client=alice key_suffix=BBBBBBB= outcome=failed error=transaction-failed' "$(<"$LOG_DIR/wg-manager.log")" 'remove failure is audited'
begin_test 'TUI remove artifact recovery failure is critical'
prepare_m10_remove_fixture
load_client_model >/dev/null
export M6_ARCHIVE_REMOVE_FAIL_INDEX=1 M6_ARCHIVE_RECOVERY_FAIL_INDEX=1
printf 'alice — 192.0.2.2/32 — BBBBBBBB\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_remove_flow 2>&1); status=$?; set -e
unset M6_ARCHIVE_REMOVE_FAIL_INDEX M6_ARCHIVE_RECOVERY_FAIL_INDEX
assert_equal "$EXIT_ROLLBACK" "$status" 'artifact recovery returns critical status'
assert_contains 'КРИТИЧНО' "$output" 'artifact recovery reports critical error'
prepare_m10_remove_fixture
load_client_model >/dev/null
export M6_ARCHIVE_REMOVE_FAIL_INDEX=1 M6_ARCHIVE_RECOVERY_FAIL_INDEX=1
printf 'Удалить\nalice — 192.0.2.2/32 — BBBBBBBB\ny\nВыход\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_screen >/dev/null 2>"${TEST_ROOT}/screen-critical.err"; status=$?; set -e
unset M6_ARCHIVE_REMOVE_FAIL_INDEX M6_ARCHIVE_RECOVERY_FAIL_INDEX
assert_equal "$EXIT_ROLLBACK" "$status" 'TUI screen propagates critical remove failure'
assert_contains 'КРИТИЧНО' "$(<"${TEST_ROOT}/screen-critical.err")" 'screen critical diagnostic'
;;
tui-client-actions)
prepare_m10_remove_fixture
load_client_model >/dev/null
begin_test 'managed client details use the selected exact identity'
printf 'alice — BBBBBBBB\nДетали\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'managed details succeed'
assert_contains 'Публичный ключ: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$(<"$GUM_PAGER_CAPTURE")" 'details show selected key'
begin_test 'config denial does not read or page client config'
printf 'alice — BBBBBBBB\nПоказать конфигурацию\nn\n' >"${TEST_ROOT}/gum-script"; : >"$GUM_PAGER_CAPTURE"
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'config denial is normal'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]|\[Peer\]' "$GUM_PAGER_CAPTURE" || true)" 'denied config is not paged'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]' "$TEST_MOCK_LOG" || true)" 'denied config stays out of gum log'
begin_test 'confirmed config reaches pager only after confirmation'
printf 'alice — BBBBBBBB\nПоказать конфигурацию\ny\n' >"${TEST_ROOT}/gum-script"; : >"$GUM_PAGER_CAPTURE"
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'confirmed config succeeds'
assert_contains 'PrivateKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=' "$(<"$GUM_PAGER_CAPTURE")" 'confirmed config reaches pager stdin'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'confirmed config absent from gum argv log'
begin_test 'QR denial never invokes qrencode'
prepare_m10_remove_fixture
load_client_model >/dev/null
printf 'alice — BBBBBBBB\nПоказать QR\nn\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"; : >"$TEST_MOCK_LOG"
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'QR denial is normal'
assert_equal 0 "$(grep -c '^qrencode ' "$TEST_MOCK_LOG" || true)" 'QR denial does not invoke qrencode'
begin_test 'confirmed QR uses exact argv and config stdin'
export QR_MOCK_EXPECTED_FILE="$CLIENT_DIR/alice.conf"
printf 'alice — BBBBBBBB\nПоказать QR\ny\n' >"${TEST_ROOT}/gum-script"; : >"$TEST_MOCK_LOG"
set +e; output=$(tui_select_client 2>"${TEST_ROOT}/qr-action.err"); status=$?; set -e
assert_equal 0 "$status" 'confirmed QR succeeds'
assert_contains 'MOCK-QR' "$output" 'QR output is returned'
assert_equal 1 "$(grep -c '^qrencode -t ansiutf8$' "$TEST_MOCK_LOG" || true)" 'QR argv is exact'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" "${TEST_ROOT}/qr-action.err" || true)" 'QR action logs stay secret-free'
unset QR_MOCK_EXPECTED_FILE
begin_test 'missing qrencode is a normal fallback'
prepare_m10_remove_fixture
load_client_model >/dev/null
local saved_path=$PATH
mkdir -p -- "${TEST_ROOT}/qr-no-command"
for utility in bash env wg cat stat sort paste date tail mv; do ln -s -- "$(command -v "$utility")" "${TEST_ROOT}/qr-no-command/$utility"; done
PATH="${TEST_ROOT}/qr-no-command"
printf 'alice — BBBBBBBB\nПоказать QR\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script"
set +e; output=$(tui_select_client 2>"${TEST_ROOT}/qr-missing-action.err"); status=$?; set -e
PATH=$saved_path
assert_equal 0 "$status" 'missing QR dependency returns normally'
assert_contains "${CLIENT_DIR}/alice.conf" "$output" 'missing QR fallback gives config path'
assert_contains 'qrencode не найден' "$(<"${TEST_ROOT}/qr-missing-action.err")" 'missing QR fallback is actionable'
begin_test 'qrencode failure preserves backend error status'
prepare_m10_remove_fixture
load_client_model >/dev/null
export QR_MOCK_FAIL=1
printf 'alice — BBBBBBBB\nПоказать QR\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script"
set +e; tui_select_client >/dev/null 2>"${TEST_ROOT}/qr-fail-action.err"; status=$?; set -e
unset QR_MOCK_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'QR failure is nonzero'
assert_contains 'QR-код недоступен' "$(<"${TEST_ROOT}/qr-fail-action.err")" 'QR failure diagnostic is preserved'
begin_test 'unmanaged client has details and back actions only'
prepare_m10_remove_fixture
load_client_model >/dev/null
CLIENT_NAMES+=(unmanaged); CLIENT_KEYS+=(EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=); CLIENT_ADDRESSES+=(192.0.2.9/32); CLIENT_ENDPOINTS+=(-); CLIENT_HANDSHAKES+=(0); CLIENT_RX+=(0); CLIENT_TX+=(0); CLIENT_STATES+=(unmanaged); CLIENT_ORIGINS+=(unmanaged)
printf 'Детали\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script"; : >"$GUM_CHOICES_CAPTURE"
set +e; tui_client_action_flow unmanaged 1 >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'unmanaged details remain readable'
assert_contains 'Детали' "$(<"$GUM_CHOICES_CAPTURE")" 'unmanaged action list includes details'
assert_contains 'Назад' "$(<"$GUM_CHOICES_CAPTURE")" 'unmanaged action list includes back'
assert_equal '' "$(grep -E 'Показать QR|Удалить|Показать конфигурацию' "$GUM_CHOICES_CAPTURE" || true)" 'unmanaged actions exclude config/QR/remove'
begin_test 'managed remove action uses exact shared backend'
prepare_m10_remove_fixture
load_client_model >/dev/null
cp -- "$TEST_ROOT/remove-dumps/4" "$TEST_ROOT/remove-dumps/3"
printf 'alice — BBBBBBBB\nУдалить\ny\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script"
set +e; output=$(tui_select_client 2>"${TEST_ROOT}/action-remove.err"); status=$?; set -e
assert_equal 0 "$status" 'managed remove action succeeds'
assert_equal 0 "$(grep -c 'PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "$WG_CONF" || true)" 'managed remove action removes selected peer'
assert_contains 'Клиент удалён' "$output" 'managed remove action reports outcome'
assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey' "$TEST_MOCK_LOG" "${TEST_ROOT}/action-remove.err" || true)" 'managed action remains secret-free'
begin_test 'client action cancellation is harmless'
prepare_m10_remove_fixture
load_client_model >/dev/null
printf '\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="$TEST_ROOT/gum-script"
set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'client action cancellation returns normally'
;;
tui-backup)
prepare_m9_fixture
begin_test 'TUI backup action creates a real validated backup and reports its path'
printf 'Создать резервную копию\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_backup_flow 2>&1); status=$?; set -e
assert_equal 0 "$status" 'real backup action success'
assert_contains 'Резервная копия создана:' "$output" 'real backup path is displayed'
assert_equal 2 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'real backup directory exists'
begin_test 'TUI backup cancellation and validation failure preserve state'
before_backup=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | sort)
printf 'Отмена\n' >"${TEST_ROOT}/gum-script"
set +e; tui_backup_flow >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'backup cancellation'
assert_equal "$before_backup" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | sort)" 'cancel leaves backups unchanged'
mv -- "$BACKUP_DIR" "${TEST_ROOT}/backups-real"
ln -s -- "${TEST_ROOT}/backups-real" "$BACKUP_DIR"
printf 'Создать резервную копию\n' >"${TEST_ROOT}/gum-script"
set +e; tui_backup_flow >/dev/null 2>&1; status=$?; set -e
assert_failure "$status" 'unsafe backup root failure is preserved'
rm -- "$BACKUP_DIR"
mv -- "${TEST_ROOT}/backups-real" "$BACKUP_DIR"
;;
tui-restore)
prepare_m10_restore_fixture
begin_test 'TUI restore real success applies validated backup and audits it'
backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/}
printf '\n# changed before restore\n' >>"$WG_CONF"
printf 'Восстановить резервную копию\n%s\n%s\n' "$restore_id" "$restore_id" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_backup_flow 2>"${TEST_ROOT}/restore.err"); status=$?; set -e
assert_equal 0 "$status" 'real restore succeeds'
assert_equal "$(sha256sum "$backup_path/wg.conf" | awk '{print $1}')" "$(sha256sum "$WG_CONF" | awk '{print $1}')" 'restore applies exact backup bytes'
assert_contains "$restore_id" "$(<"$GUM_PAGER_CAPTURE")" 'restore preview shows selected id'
assert_contains 'manual' "$(<"$GUM_PAGER_CAPTURE")" 'restore preview shows operation details'
assert_equal 2 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'restore keeps current-state and selected backups'
assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'restore applies runtime configuration'
assert_contains 'operation=restore' "$(<"$LOG_DIR/wg-manager.log")" 'restore audit is recorded'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|AAAAAAAA|BBBBBBBB' "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "$TEST_ROOT/restore.err" || true)" 'restore output and logs are secret-free'
begin_test 'TUI restore cancellation and typed mismatch preserve state'
prepare_m10_restore_fixture
backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/}
before_hash=$(sha256sum "$WG_CONF"); before_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)
printf 'Восстановить резервную копию\n%s\nwrong-id\n' "$restore_id" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_backup_flow >/dev/null 2>&1; status=$?; set -e
assert_equal 0 "$status" 'typed restore mismatch is harmless'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'typed mismatch preserves config'
assert_equal "$before_count" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'typed mismatch creates no backup'
begin_test 'TUI restore rejects an unoffered selection without mutation'
prepare_m10_restore_fixture
backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/}; before_hash=$(sha256sum "$WG_CONF")
printf 'Восстановить резервную копию\nnot-a-backup\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_backup_flow >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_VALIDATION" "$status" 'unoffered restore selection is rejected'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'unoffered restore leaves config unchanged'
begin_test 'TUI restore apply failure rolls back and retains backup'
prepare_m10_restore_fixture
backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/}; printf '\n# changed before failed restore\n' >>"$WG_CONF"; before_hash=$(sha256sum "$WG_CONF")
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/restore-sync-fail"
printf 'Восстановить резервную копию\n%s\n%s\n' "$restore_id" "$restore_id" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_backup_flow >/dev/null 2>"${TEST_ROOT}/restore-fail.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'restore apply failure returns operation'
assert_equal "$before_hash" "$(sha256sum "$WG_CONF")" 'restore apply failure restores attempted state'
assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'restore rollback is audited'
assert_contains 'operation=restore target='"$restore_id"' client='"$restore_id"' key_suffix= outcome=failed error=transaction-failed' "$(<"$LOG_DIR/wg-manager.log")" 'restore failure is audited'
assert_equal 2 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'restore failure retains backup copies'
begin_test 'TUI screen propagates critical restore rollback'
prepare_m10_restore_fixture
backup_path=$(create_backup "$WG_CONF" manual "$WG_INTERFACE"); restore_id=${backup_path##*/}; printf '\n# changed before critical restore\n' >>"$WG_CONF"
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/restore-critical-sync" TRANSACTION_ROLLBACK_REPLACE_FAIL=1
printf 'Резервные копии\nВосстановить резервную копию\n%s\n%s\nВыход\n' "$restore_id" "$restore_id" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_screen >/dev/null 2>"${TEST_ROOT}/restore-critical.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL
assert_equal "$EXIT_ROLLBACK" "$status" 'critical restore rollback exits nonzero'
assert_contains 'КРИТИЧНО' "$(<"${TEST_ROOT}/restore-critical.err")" 'critical restore diagnostic is preserved'
;;
tui-backend-failure)
prepare_m9_fixture
begin_test 'TUI ordinary backup failure remains readable and bounded'
rm -rf -- "$BACKUP_DIR"
printf 'not a directory\n' >"$BACKUP_DIR"
before_backup=0
printf 'Резервные копии\nСоздать резервную копию\nВыход\n' >"${TEST_ROOT}/gum-script"
export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; output=$(tui_screen 2>"${TEST_ROOT}/backend-failure.err"); status=$?; set -e
rm -- "$BACKUP_DIR"
mkdir -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"
assert_equal 0 "$status" 'ordinary backend failure returns to menu'
assert_contains 'Предупреждение' "$(<"${TEST_ROOT}/backend-failure.err")" 'ordinary failure has context'
assert_equal "$before_backup" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'failed backup creates no entry'
begin_test 'TUI repeated real backend failures are bounded'
prepare_m9_fixture
rm -rf -- "$BACKUP_DIR"
printf 'not a directory\n' >"$BACKUP_DIR"
before_backup=0
printf 'Резервные копии\nСоздать резервную копию\nРезервные копии\nСоздать резервную копию\nРезервные копии\nСоздать резервную копию\nВыход\n' >"${TEST_ROOT}/gum-script"
export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_screen >"${TEST_ROOT}/bounded.out" 2>"${TEST_ROOT}/bounded.err"; status=$?; set -e
rm -- "$BACKUP_DIR"
mkdir -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"
assert_equal "$EXIT_OPERATION" "$status" 'three real failures terminate with operation status'
assert_contains 'TUI завершён' "$(<"${TEST_ROOT}/bounded.err")" 'bounded failure diagnostic'
assert_equal "$before_backup" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'repeated failures create no backups'
begin_test 'TUI critical restore rollback propagates without stubs'
prepare_m10_restore_fixture
local critical_backup critical_id
critical_backup=$(create_backup "$WG_CONF" manual "$WG_INTERFACE")
critical_id=${critical_backup##*/}
printf '\n# changed before critical TUI restore\n' >>"$WG_CONF"
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/critical-sync" TRANSACTION_ROLLBACK_REPLACE_FAIL=1
printf 'Резервные копии\nВосстановить резервную копию\n%s\n%s\nВыход\n' "$critical_id" "$critical_id" >"${TEST_ROOT}/gum-script"
export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_screen >"${TEST_ROOT}/critical.out" 2>"${TEST_ROOT}/critical.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL
assert_equal "$EXIT_ROLLBACK" "$status" 'critical rollback exits TUI immediately'
assert_contains 'КРИТИЧНО' "$(<"${TEST_ROOT}/critical.err")" 'critical rollback diagnostic is preserved'
assert_equal '' "${TUI_GUM_PATH:-}" 'critical route clears gum state'
;;
tui-no-secret-args)
prepare_m10_add_fixture
begin_test 'successful add keeps secrets out of TUI arguments and audit'
printf 'secretcheck\nПолный туннель\ny\ny\nn\n' >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; add_output=$(tui_add_flow 2>"${TEST_ROOT}/secret-add.err"); status=$?; set -e
assert_equal 0 "$status" 'real add for secret boundary succeeds'
assert_equal 600 "$(stat -c '%a' "$CLIENT_DIR/secretcheck.conf")" 'generated config is restrictive'
assert_contains 'secretcheck.conf' "$add_output" 'successful add reports validated path'
secret_stream=$(cat "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "${TEST_ROOT}/secret-add.err"; printf '%s\n' "$add_output")
assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' <<<"$secret_stream" || true)" 'successful add outputs and logs are secret-free'
unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_MOCK_DUMP_FILE="${TEST_ROOT}/dump-sequence/1"
load_client_model >/dev/null
local secret_label
secret_label='secretcheck — FFFFFFFF'
begin_test 'confirmed config is sent only to pager stdin'
: >"$GUM_PAGER_CAPTURE"; : >"$TEST_MOCK_LOG"
printf '%s\nПоказать конфигурацию\ny\n' "$secret_label" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_select_client >"${TEST_ROOT}/secret-config.out" 2>"${TEST_ROOT}/secret-config.err"; status=$?; set -e
assert_equal 0 "$status" 'confirmed config action succeeds'
assert_contains 'PrivateKey' "$(<"$GUM_PAGER_CAPTURE")" 'confirmed config reaches pager stdin'
secret_stream=$(cat "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "${TEST_ROOT}/secret-config.out" "${TEST_ROOT}/secret-config.err")
assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' <<<"$secret_stream" || true)" 'config secrets absent from args/logs/output'
begin_test 'confirmed QR receives config on stdin without leaking it to gum'
export QR_MOCK_EXPECTED_FILE="$CLIENT_DIR/secretcheck.conf"
: >"$TEST_MOCK_LOG"
printf '%s\nПоказать QR\ny\n' "$secret_label" >"${TEST_ROOT}/gum-script"; export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-script"
set +e; tui_select_client >"${TEST_ROOT}/secret-qr.out" 2>"${TEST_ROOT}/secret-qr.err"; status=$?; set -e
unset QR_MOCK_EXPECTED_FILE
assert_equal 0 "$status" 'confirmed QR action succeeds'
assert_contains 'MOCK-QR' "$(<"${TEST_ROOT}/secret-qr.out")" 'QR output is returned'
assert_contains 'qrencode -t ansiutf8' "$(<"$TEST_MOCK_LOG")" 'QR argv is exact and nonsecret'
secret_stream=$(cat "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" "${TEST_ROOT}/secret-qr.out" "${TEST_ROOT}/secret-qr.err")
assert_equal '' "$(grep -E 'EEEE|GGGG|PrivateKey|PresharedKey|\[Interface\]|\[Peer\]' <<<"$secret_stream" || true)" 'QR secrets absent from args/logs/output'
;;
tui-no-gum) tui-no-gum)
prepare_m9_fixture prepare_m9_fixture
export WG_MANAGER_GUM="${TEST_ROOT}/missing-gum" export WG_MANAGER_GUM="${TEST_ROOT}/missing-gum"
@@ -2587,6 +3175,9 @@ EOF
set +e; output=$(remove_client alice extra 2>&1); status=$?; set -e set +e; output=$(remove_client alice extra 2>&1); status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'лишний аргумент remove отклонён' assert_equal "$EXIT_USAGE" "$status" 'лишний аргумент remove отклонён'
assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'лишний аргумент не меняет конфиг' assert_equal "$remove_before" "$(sha256sum -- "$fixture")" 'лишний аргумент не меняет конфиг'
set +e; output=$(remove_client alice --yes --expected-key BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= 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 set +e; output=$(/bin/bash "$PROJECT_DIR/wg-manager.sh" remove 2>&1); status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'отсутствующее имя remove отклонено' assert_equal "$EXIT_USAGE" "$status" 'отсутствующее имя remove отклонено'
set +e; output=$(remove_client absent --yes 2>&1); status=$?; set -e set +e; output=$(remove_client absent --yes 2>&1); status=$?; set -e
@@ -3502,7 +4093,7 @@ elif [[ $test_group == add-client || $test_group == address-allocation || $test_
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 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 run_m6_tests
elif [[ $test_group == gum-resolution || $test_group == tui-routing || $test_group == tui-cancel || $test_group == tui-read-only || $test_group == tui-no-gum || $test_group == tui-non-tty ]]; then elif [[ $test_group == gum-resolution || $test_group == tui-routing || $test_group == tui-cancel || $test_group == tui-read-only || $test_group == tui-no-gum || $test_group == tui-non-tty || $test_group == tui-add || $test_group == tui-remove || $test_group == tui-client-actions || $test_group == tui-backup || $test_group == tui-restore || $test_group == tui-backend-failure || $test_group == tui-no-secret-args ]]; then
run_m9_tests run_m9_tests
elif [[ $test_group == audit || $test_group == audit-no-secrets || $test_group == backup-list || $test_group == restore || $test_group == restore-invalid || $test_group == restore-rollback || $test_group == backup-path-safety || $test_group == runtime-file-modes ]]; then elif [[ $test_group == audit || $test_group == audit-no-secrets || $test_group == backup-list || $test_group == restore || $test_group == restore-invalid || $test_group == restore-rollback || $test_group == backup-path-safety || $test_group == runtime-file-modes ]]; then
run_m8_tests run_m8_tests
@@ -3530,7 +4121,7 @@ else
run_m8_tests run_m8_tests
done done
unset m8_group unset m8_group
for m9_group in gum-resolution tui-routing tui-cancel tui-read-only tui-no-gum tui-non-tty; do for m9_group in 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; do
test_group=$m9_group test_group=$m9_group
run_m9_tests run_m9_tests
done done
+294 -26
View File
@@ -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 NAME [--yes]|diagnose [--quiet]|backup|backups|restore BACKUP [--yes]|log [LIMIT]|tui} wg-manager.sh {status|list|add NAME [--mode MODE] [--split-networks CIDRS] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose [--quiet]|backup|backups|restore BACKUP [--yes]|log [LIMIT]|tui}
Команды управления WireGuard будут добавлены в следующих версиях. Команды управления WireGuard будут добавлены в следующих версиях.
USAGE USAGE
@@ -836,7 +836,7 @@ transaction_subshell_exit() {
local status=$? local status=$?
transaction_cleanup transaction_cleanup
if [[ -n ${TRANSACTION_RESULT_FILE:-} ]]; then if [[ -n ${TRANSACTION_RESULT_FILE:-} ]]; then
printf 'STATUS=%s\nBACKUP=%s\nOUTCOME=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" "${TRANSACTION_OUTCOME:-failed-before-apply}" >"$TRANSACTION_RESULT_FILE" printf 'STATUS=%s\nBACKUP=%s\nFINALIZER=%s\nOUTCOME=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" "${REMOVE_ARCHIVE_PATH:-}" "${TRANSACTION_OUTCOME:-failed-before-apply}" >"$TRANSACTION_RESULT_FILE"
if ! chmod 600 -- "$TRANSACTION_RESULT_FILE"; then if ! chmod 600 -- "$TRANSACTION_RESULT_FILE"; then
printf 'Предупреждение: не удалось защитить transaction result\n' >&2 printf 'Предупреждение: не удалось защитить transaction result\n' >&2
fi fi
@@ -986,7 +986,7 @@ run_transaction() {
die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции' die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции'
return $? return $?
fi fi
local operation=$1 target=$2 builder=$3 result_file result_status result_backup result_outcome had_errexit=0 local operation=$1 target=$2 builder=$3 result_file result_status result_backup result_finalizer result_outcome had_errexit=0
shift 3 shift 3
[[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; } [[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; }
set_runtime_paths || return $? set_runtime_paths || return $?
@@ -1005,11 +1005,12 @@ run_transaction() {
(( had_errexit == 1 )) && set -e || set +e (( had_errexit == 1 )) && set -e || set +e
if [[ -r $result_file ]]; then if [[ -r $result_file ]]; then
while IFS='=' read -r key value; do while IFS='=' read -r key value; do
case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; OUTCOME) result_outcome=$value ;; esac case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; FINALIZER) result_finalizer=$value ;; OUTCOME) result_outcome=$value ;; esac
done <"$result_file" done <"$result_file"
fi fi
rm -f -- "$result_file" rm -f -- "$result_file"
TRANSACTION_BACKUP_PATH=${result_backup:-} TRANSACTION_BACKUP_PATH=${result_backup:-}
[[ -n ${result_finalizer:-} ]] && REMOVE_ARCHIVE_RESULT=$result_finalizer
TRANSACTION_OUTCOME=${result_outcome:-failed-before-apply} TRANSACTION_OUTCOME=${result_outcome:-failed-before-apply}
if [[ $result_outcome == success ]]; then if [[ $result_outcome == success ]]; then
if ! write_audit_event apply "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит применения'; fi if ! write_audit_event apply "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит применения'; fi
@@ -1097,7 +1098,7 @@ allocate_client_ipv6() {
client_allowed_ips() { client_allowed_ips() {
case $1 in case $1 in
full-tunnel) printf '0.0.0.0/0,::/0' ;; full-tunnel) printf '0.0.0.0/0,::/0' ;;
split-tunnel) printf '%s.0/24' "$VPN_SUBNET_PREFIX" ;; split-tunnel) printf '%s' "${ADD_SPLIT_NETWORKS:-${VPN_SUBNET_PREFIX}.0/24}" ;;
*) return 1 ;; *) return 1 ;;
esac esac
} }
@@ -1294,12 +1295,12 @@ clear_add_transaction_state() {
for path in "$ADD_META_STAGE" "$ADD_CONFIG_STAGE" "$ADD_KEY_STAGE" "$ADD_PUB_STAGE" "$ADD_PSK_STAGE"; do for path in "$ADD_META_STAGE" "$ADD_CONFIG_STAGE" "$ADD_KEY_STAGE" "$ADD_PUB_STAGE" "$ADD_PSK_STAGE"; do
[[ -n ${path:-} ]] && rm -f -- "$path" [[ -n ${path:-} ]] && rm -f -- "$path"
done done
unset ADD_NAME ADD_MODE ADD_ENDPOINT ADD_PRIVATE_KEY ADD_PUBLIC_KEY ADD_PSK ADD_META_STAGE ADD_CONFIG_STAGE ADD_KEY_STAGE ADD_PUB_STAGE ADD_PSK_STAGE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX unset ADD_NAME ADD_MODE ADD_ENDPOINT ADD_SPLIT_NETWORKS ADD_PRIVATE_KEY ADD_PUBLIC_KEY ADD_PSK ADD_META_STAGE ADD_CONFIG_STAGE ADD_KEY_STAGE ADD_PUB_STAGE ADD_PSK_STAGE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX
TRANSACTION_FINALIZER='' TRANSACTION_FINALIZER=''
} }
add_client_impl() { add_client_impl() {
local name mode endpoint arg mode_seen=0 endpoint_seen=0 local name mode endpoint arg split_networks='' mode_seen=0 endpoint_seen=0 split_seen=0
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; } (($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; }
name=$1; shift name=$1; shift
load_config || return $? load_config || return $?
@@ -1314,11 +1315,22 @@ add_client_impl() {
(( endpoint_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --endpoint указан повторно'; return $?; } (( endpoint_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --endpoint указан повторно'; return $?; }
(($# >= 2)) || { die "$EXIT_USAGE" 'Для --endpoint требуется значение'; return $?; } (($# >= 2)) || { die "$EXIT_USAGE" 'Для --endpoint требуется значение'; return $?; }
endpoint=$2; endpoint_seen=1; shift 2 ;; endpoint=$2; endpoint_seen=1; shift 2 ;;
--split-networks)
(( split_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --split-networks указан повторно'; return $?; }
(($# >= 2)) || { die "$EXIT_USAGE" 'Для --split-networks требуется значение'; return $?; }
split_networks=$2; split_seen=1; shift 2 ;;
*) die "$EXIT_USAGE" 'Неизвестный или лишний аргумент add'; return $? ;; *) die "$EXIT_USAGE" 'Неизвестный или лишний аргумент add'; return $? ;;
esac esac
done done
validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; } validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
[[ $mode == full-tunnel || $mode == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; } [[ $mode == full-tunnel || $mode == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; }
if [[ $mode == split-tunnel ]]; then
[[ -n $split_networks ]] || split_networks="${VPN_SUBNET_PREFIX}.0/24"
split_networks=$(normalize_allowed_ips "$split_networks") || { die "$EXIT_VALIDATION" 'Некорректные сети split-tunnel'; return $?; }
elif [[ -n $split_networks ]]; then
die "$EXIT_VALIDATION" '--split-networks доступен только для split-tunnel'
return $?
fi
validate_endpoint "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный endpoint клиента'; return $?; } validate_endpoint "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный endpoint клиента'; return $?; }
require_add_privilege || return $? require_add_privilege || return $?
ADD_META_STAGE='' ADD_CONFIG_STAGE='' ADD_KEY_STAGE='' ADD_PUB_STAGE='' ADD_PSK_STAGE='' ADD_META_STAGE='' ADD_CONFIG_STAGE='' ADD_KEY_STAGE='' ADD_PUB_STAGE='' ADD_PSK_STAGE=''
@@ -1367,7 +1379,7 @@ add_client_impl() {
clear_add_transaction_state clear_add_transaction_state
return "$EXIT_OPERATION" return "$EXIT_OPERATION"
fi fi
ADD_NAME=$name ADD_MODE=$mode ADD_ENDPOINT=$endpoint ADD_NAME=$name ADD_MODE=$mode ADD_ENDPOINT=$endpoint ADD_SPLIT_NETWORKS=$split_networks
TRANSACTION_FINALIZER=publish_add_artifacts TRANSACTION_FINALIZER=publish_add_artifacts
local status local status
if run_transaction add "$name" add_client_builder; then if run_transaction add "$name" add_client_builder; then
@@ -1376,7 +1388,7 @@ add_client_impl() {
status=$? status=$?
fi fi
if (( status != 0 )); then if (( status != 0 )); then
if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" failed transaction_failed >/dev/null 2>&1; then warn 'Не удалось записать аудит добавления'; fi if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" failed transaction-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит добавления'; fi
clear_add_transaction_state clear_add_transaction_state
return "$status" return "$status"
fi fi
@@ -1448,6 +1460,8 @@ gum_header() { gum_command style --border double --padding '0 1' "$1"; }
gum_choose() { gum_command choose "$@"; } gum_choose() { gum_command choose "$@"; }
gum_filter() { gum_command filter --placeholder 'Выберите клиента' "$@"; } gum_filter() { gum_command filter --placeholder 'Выберите клиента' "$@"; }
gum_input() { gum_command input "$@"; } gum_input() { gum_command input "$@"; }
gum_input_placeholder() { gum_command input --placeholder "$1"; }
gum_input_prompt() { gum_command input --prompt "$1"; }
gum_confirm() { gum_command confirm "$@"; } gum_confirm() { gum_command confirm "$@"; }
gum_table() { gum_command table "$@"; } gum_table() { gum_command table "$@"; }
gum_pager() { gum_command pager "$@"; } gum_pager() { gum_command pager "$@"; }
@@ -1496,7 +1510,202 @@ tui_select_client() {
fi fi
done done
((index >= 0)) || return "$EXIT_VALIDATION" ((index >= 0)) || return "$EXIT_VALIDATION"
tui_page_command print_client_details "${CLIENT_NAMES[$index]}" "$index" tui_client_action_flow "${CLIENT_NAMES[$index]}" "$index"
}
tui_client_action_flow() {
local name=$1 index=$2 action config_output status
local -a actions=('Детали' 'Показать конфигурацию' 'Показать QR' 'Удалить' 'Назад')
if [[ ${CLIENT_ORIGINS[$index]} != managed ]]; then actions=('Детали' 'Назад'); fi
action=$(gum_choose "${actions[@]}") || return 0
case $action in
'Детали') tui_page_command print_client_details "$name" "$index" ;;
'Показать конфигурацию')
gum_confirm 'Показать полную конфигурацию?' || return 0
if config_output=$(export_client_config_exact "$name" "${CLIENT_KEYS[$index]}" 1); then
printf '%s' "$config_output" | gum_pager
else status=$?; return "$status"; fi
;;
'Показать QR')
gum_confirm 'Показать QR-код?' || return 0
if qr_client_exact "$name" "${CLIENT_KEYS[$index]}"; then
:
else
status=$?
if (( status == EXIT_DEPENDENCY )); then
printf 'QR-код недоступен; путь конфигурации показан выше\n' >&2
return 0
fi
printf 'QR-код недоступен или не создан\n' >&2
return "$status"
fi
;;
'Удалить') tui_remove_flow_for_index "$name" "$index" ;;
*) return 0 ;;
esac
}
tui_add_flow() {
local name mode status networks endpoint preview config_path endpoint_prompt endpoint_attempts=0
local -a add_args=()
local attempts=0
while ((attempts < 2)); do
name=$(gum_input_placeholder 'Имя клиента') || return 0
if validate_client_name "$name"; then break; fi
printf 'Некорректное имя клиента, повторите ввод\n' >&2
((attempts += 1)); ((attempts < 2)) || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
done
mode=$(gum_choose 'Полный туннель' 'Выборочные сети') || return 0
case $mode in
'Полный туннель') mode=full-tunnel ;;
'Выборочные сети') mode=split-tunnel ;;
*) return "$EXIT_VALIDATION" ;;
esac
networks=''
if [[ $mode == split-tunnel ]]; then
networks=$(gum_input_placeholder 'Сети через запятую') || return 0
networks=$(normalize_allowed_ips "$networks") || { die "$EXIT_VALIDATION" 'Некорректные сети split-tunnel'; return $?; }
fi
endpoint=$SERVER_ENDPOINT
endpoint_prompt=$(tui_wrap_value 'Использовать endpoint:' "$endpoint")
if ! gum_confirm "$endpoint_prompt"; then
while ((endpoint_attempts < 2)); do
endpoint=$(gum_input_placeholder 'Endpoint HOST') || return 0
if validate_endpoint "$endpoint"; then break; fi
printf 'Некорректный endpoint, повторите ввод\n' >&2
((endpoint_attempts += 1))
done
validate_endpoint "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный endpoint'; return $?; }
fi
printf -v preview 'Имя: %s\nРежим: %s\nСети: %s\nEndpoint: %s' "$name" "$mode" "${networks:--}" "$endpoint"
gum_pager <<<"$preview" || return $?
gum_confirm 'Создать клиента с этими параметрами?' || return 0
add_args=("$name" --mode "$mode" --endpoint "$endpoint")
[[ -n $networks ]] && add_args+=(--split-networks "$networks")
if add_client "${add_args[@]}"; then
config_path=$(export_client_config "$name" 0) || return $?
printf 'Клиент добавлен: %s\nКонфигурация: %s\n' "$name" "$config_path"
if gum_confirm 'Показать QR-код?'; then
if qr_client "$name"; then
:
else
status=$?
if (( status == EXIT_DEPENDENCY )); then
printf 'QR-код недоступен; путь конфигурации показан выше\n' >&2
return 0
fi
printf 'QR-код недоступен или не создан\n' >&2
return "$status"
fi
fi
else status=$?; printf 'Добавление клиента не выполнено\n' >&2; return "$status"; fi
}
tui_remove_flow() {
local index selected_index=-1 name key label base previous ordinal duplicate
load_client_model || return $?
local -a choices=() choice_indices=()
for index in "${!CLIENT_KEYS[@]}"; do
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || continue
base="${CLIENT_NAMES[$index]:0:32} — ${CLIENT_ADDRESSES[$index]:0:24} — ${CLIENT_KEYS[$index]:0:8}"
ordinal=1; duplicate=0
for previous in "${!CLIENT_KEYS[@]}"; do
[[ $previous -ge $index ]] && break
[[ ${CLIENT_ORIGINS[$previous]} == managed ]] || continue
if [[ "${CLIENT_NAMES[$previous]:0:32} — ${CLIENT_ADDRESSES[$previous]:0:24} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then ((ordinal += 1)); duplicate=1; fi
done
for previous in "${!CLIENT_KEYS[@]}"; do
((previous > index)) || continue
[[ ${CLIENT_ORIGINS[$previous]} == managed ]] || continue
if [[ "${CLIENT_NAMES[$previous]:0:32} — ${CLIENT_ADDRESSES[$previous]:0:24} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then duplicate=1; break; fi
done
if ((duplicate == 1)); then label="${CLIENT_NAMES[$index]:0:28} — ${CLIENT_ADDRESSES[$index]:0:24} — ${CLIENT_KEYS[$index]:0:8} #${ordinal}"; else label=$base; fi
choices+=("$label")
choice_indices+=("$index")
done
(("${#choices[@]} > 0")) || { printf 'Управляемые клиенты отсутствуют\n'; return 0; }
key=$(gum_filter "${choices[@]}") || return 0
for previous in "${!choices[@]}"; do
if [[ ${choices[$previous]} == "$key" ]]; then
((selected_index < 0)) || return "$EXIT_VALIDATION"
selected_index=${choice_indices[$previous]}
fi
done
((selected_index >= 0)) || return "$EXIT_VALIDATION"
name=${CLIENT_NAMES[$selected_index]}; key=${CLIENT_KEYS[$selected_index]}
local confirmation address_lines
address_lines=$(tui_wrap_value 'Адреса:' "${CLIENT_ADDRESSES[$selected_index]}")
confirmation=$(printf 'Удалить клиента:\nИмя: %s\n%s\nКлюч: *%s\nАрхивировать его файлы?' "$name" "$address_lines" "$(short_public_key "$key")")
gum_confirm "$confirmation" || return 0
if remove_client_exact "$name" "$key"; then
printf 'Клиент удалён и архивирован: %s\n' "$name"
[[ -n ${REMOVE_ARCHIVE_RESULT:-} ]] && printf 'Архив: %s\n' "$REMOVE_ARCHIVE_RESULT"
unset REMOVE_ARCHIVE_RESULT
else
return $?
fi
}
tui_remove_flow_for_index() {
local name=$1 index=$2 key=${CLIENT_KEYS[$2]} status
local confirmation address_lines
address_lines=$(tui_wrap_value 'Адреса:' "${CLIENT_ADDRESSES[$index]}")
confirmation=$(printf 'Удалить клиента:\nИмя: %s\n%s\nКлюч: *%s\nАрхивировать его файлы?' "$name" "$address_lines" "$(short_public_key "$key")")
gum_confirm "$confirmation" || return 0
if remove_client_exact "$name" "$key"; then
printf 'Клиент удалён и архивирован: %s\n' "$name"
[[ -n ${REMOVE_ARCHIVE_RESULT:-} ]] && printf 'Архив: %s\n' "$REMOVE_ARCHIVE_RESULT"
unset REMOVE_ARCHIVE_RESULT
return 0
else
status=$?
unset REMOVE_ARCHIVE_RESULT
return "$status"
fi
}
tui_backup_flow() {
local action selected listing backup_id details valid_selection
local -a backups=()
action=$(gum_choose 'Создать резервную копию' 'Восстановить резервную копию' 'Отмена') || return 0
case $action in
'Создать резервную копию')
local backup_path
if backup_path=$(manual_backup); then
printf 'Резервная копия создана: %s\n' "$backup_path"
else return $?; fi
;;
'Восстановить резервную копию')
if listing=$(list_backups); then :; else return $?; fi
local -a backup_details=()
while IFS=$'\t' read -r backup_id details; do
[[ $backup_id =~ ^[0-9]{8}T[0-9]{6}Z-[A-Za-z0-9._-]+-[0-9]{3}$ ]] || continue
backups+=("$backup_id")
backup_details+=("$details")
done <<<"$listing"
((${#backups[@]} > 0)) || { printf 'Подходящие резервные копии отсутствуют\n'; return 0; }
selected=$(gum_choose "${backups[@]}") || return 0
valid_selection=0
for backup_id in "${backups[@]}"; do [[ $backup_id == "$selected" ]] && valid_selection=1; done
((valid_selection == 1)) || { die "$EXIT_VALIDATION" 'Выбрана неизвестная резервная копия'; return $?; }
local selected_detail=''
for index in "${!backups[@]}"; do
[[ ${backups[$index]} == "$selected" ]] && selected_detail=${backup_details[$index]}
done
printf 'Резервная копия: %s\n%s\n' "$selected" "$selected_detail" | gum_pager || return $?
local typed_confirmation
typed_confirmation=$(gum_input_prompt "Введите идентификатор $selected") || return 0
[[ $typed_confirmation == "$selected" ]] || return 0
restore_backup "$selected" --yes ;;
*) return 0 ;;
esac
}
tui_run_action() {
local status
if "$@"; then return 0; else status=$?; fi
printf 'Предупреждение: действие TUI не выполнено (код %s)\n' "$status" >&2
return "$status"
} }
tui_screen() { tui_screen() {
@@ -1518,12 +1727,13 @@ tui_screen() {
if gum_header "$dashboard"; then :; else status=$?; unset TUI_GUM_PATH; return "$status"; fi if gum_header "$dashboard"; then :; else status=$?; unset TUI_GUM_PATH; return "$status"; fi
if choice=$(gum_choose "${menu_labels[@]}"); then :; else unset TUI_GUM_PATH; return 0; fi if choice=$(gum_choose "${menu_labels[@]}"); then :; else unset TUI_GUM_PATH; return 0; fi
case $choice in case $choice in
Состояние) tui_page_command print_status || ((failures += 1)) ;; Состояние) if tui_run_action tui_page_command print_status; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Клиенты) tui_select_client || ((failures += 1)) ;; Клиенты) if tui_run_action tui_select_client; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Диагностика) tui_page_command diagnose || ((failures += 1)) ;; Диагностика) if tui_run_action tui_page_command diagnose; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Журнал) tui_page_command list_audit || ((failures += 1)) ;; Журнал) if tui_run_action tui_page_command list_audit; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
'Резервные копии') tui_page_command list_backups || ((failures += 1)) ;; 'Резервные копии') if tui_run_action tui_backup_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Добавить|Удалить) printf 'Действие «%s» доступно через CLI в M9; изменения не выполняются.\n' "$choice" ;; Добавить) if tui_run_action tui_add_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Удалить) if tui_run_action tui_remove_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
Выход|'') unset TUI_GUM_PATH; return 0 ;; Выход|'') unset TUI_GUM_PATH; return 0 ;;
*) printf 'Неизвестный пункт TUI\n' >&2; ((failures += 1)) ;; *) printf 'Неизвестный пункт TUI\n' >&2; ((failures += 1)) ;;
esac esac
@@ -1768,7 +1978,7 @@ restore_backup() {
if (( status == 0 )); then if (( status == 0 )); then
write_audit_event restore "$name" success success >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Восстановление выполнено, но аудит не записан'; return "$EXIT_OPERATION"; } write_audit_event restore "$name" success success >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Восстановление выполнено, но аудит не записан'; return "$EXIT_OPERATION"; }
else else
if ! write_audit_event restore "$name" failed transaction_failed >/dev/null 2>&1; then warn 'Не удалось записать аудит восстановления'; fi if ! write_audit_event restore "$name" failed transaction-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит восстановления'; fi
fi fi
cleanup_restore_snapshot cleanup_restore_snapshot
return "$status" return "$status"
@@ -2005,13 +2215,14 @@ print_client_details() {
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then printf 'Режим: %s\nСоздан: %s\nПуть конфигурации: %s\n' "${META_MODE[$key]}" "${META_CREATED[$key]}" "$config_path"; fi if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then printf 'Режим: %s\nСоздан: %s\nПуть конфигурации: %s\n' "${META_MODE[$key]}" "${META_CREATED[$key]}" "$config_path"; fi
} }
export_client_config() { export_client_config_core() {
local name=$1 show=${2:-0} index key path status=0 local name=$1 show=${2:-0} index key path status=0 expected_key=${3:-}
[[ $show == 0 || $show == 1 ]] || { die "$EXIT_USAGE" 'Недопустимый режим экспорта конфигурации'; return $?; } [[ $show == 0 || $show == 1 ]] || { die "$EXIT_USAGE" 'Недопустимый режим экспорта конфигурации'; return $?; }
load_client_model || return $? load_client_model || return $?
index=$(resolve_named_client "$name") || return $? index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; } [[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
key=${CLIENT_KEYS[$index]} key=${CLIENT_KEYS[$index]}
[[ -z $expected_key || $expected_key == "$key" ]] || { die "$EXIT_VALIDATION" 'Идентичность клиента изменилась'; return $?; }
path=$(validate_client_config_path "$key" "$name" "$index") || return $? path=$(validate_client_config_path "$key" "$name" "$index") || return $?
if (( show == 0 )); then printf '%s\n' "$path"; return 0; fi if (( show == 0 )); then printf '%s\n' "$path"; return 0; fi
if open_validated_client_config "$path"; then :; else if open_validated_client_config "$path"; then :; else
@@ -2031,6 +2242,16 @@ export_client_config() {
if (( status != 0 )); then die "$EXIT_OPERATION" 'Не удалось прочитать конфигурацию клиента'; return $?; fi if (( status != 0 )); then die "$EXIT_OPERATION" 'Не удалось прочитать конфигурацию клиента'; return $?; fi
} }
export_client_config() {
(($# >= 1 && $# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы config'; return $?; }
export_client_config_core "$1" "${2:-0}"
}
export_client_config_exact() {
local name=$1 expected_key=$2 show=${3:-0}
export_client_config_core "$name" "$show" "$expected_key"
}
show_client() { show_client() {
local name=$1 index local name=$1 index
load_client_model || return $? load_client_model || return $?
@@ -2038,12 +2259,13 @@ show_client() {
print_client_details "$name" "$index" print_client_details "$name" "$index"
} }
qr_client() { qr_client_core() {
local name=$1 index key path qr_path status local name=$1 expected_key=${2:-} index key path qr_path status
load_client_model || return $? load_client_model || return $?
index=$(resolve_named_client "$name") || return $? index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; } [[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
key=${CLIENT_KEYS[$index]} key=${CLIENT_KEYS[$index]}
[[ -z $expected_key || $expected_key == "$key" ]] || { die "$EXIT_VALIDATION" 'Идентичность клиента изменилась'; return $?; }
path=$(validate_client_config_path "$key" "$name" "$index") || return $? path=$(validate_client_config_path "$key" "$name" "$index") || return $?
if qr_path=$(command -v qrencode 2>/dev/null); then :; else qr_path=''; fi if qr_path=$(command -v qrencode 2>/dev/null); then :; else qr_path=''; fi
if [[ -z $qr_path || ! -f $qr_path || -L $qr_path || ! -x $qr_path ]]; then if [[ -z $qr_path || ! -f $qr_path || -L $qr_path || ! -x $qr_path ]]; then
@@ -2068,8 +2290,27 @@ qr_client() {
(( status == 0 )) || { die "$EXIT_OPERATION" 'Не удалось сформировать QR-код'; return "$EXIT_OPERATION"; } (( status == 0 )) || { die "$EXIT_OPERATION" 'Не удалось сформировать QR-код'; return "$EXIT_OPERATION"; }
} }
qr_client() {
(($# == 1)) || { die "$EXIT_USAGE" 'Лишние аргументы qr'; return $?; }
qr_client_core "$1"
}
qr_client_exact() {
local name=$1 expected_key=$2
qr_client_core "$name" "$expected_key"
}
short_public_key() { local key=$1; printf '%s' "${key: -8}"; } short_public_key() { local key=$1; printf '%s' "${key: -8}"; }
tui_wrap_value() {
local label=$1 value=$2 width=72
printf '%s\n' "$label"
while [[ -n $value ]]; do
printf '%s\n' "${value:0:width}"
value=${value:width}
done
}
remove_peer_builder() { remove_peer_builder() {
local stage=$1 target_key=${REMOVE_KEY:?} target_name=${REMOVE_NAME:?} local stage=$1 target_key=${REMOVE_KEY:?} target_name=${REMOVE_NAME:?}
local -a lines=() kept=() block=() local -a lines=() kept=() block=()
@@ -2312,17 +2553,20 @@ confirm_remove() {
remove_is_interactive() { [[ -t 0 && -t 1 ]]; } remove_is_interactive() { [[ -t 0 && -t 1 ]]; }
remove_client_impl() { remove_client_core() {
local name=$1 flag=${2:-} index key path status answer local name=$1 expected_key=$2 flag=$3 index key path status answer
(($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы remove'; return $?; } REMOVE_ARCHIVE_RESULT=''
validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; } validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
[[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг remove --yes'; return $?; }
load_client_model || return $? load_client_model || return $?
path_components_safe "$ARCHIVE_DIR" || { die "$EXIT_VALIDATION" 'Каталог архива содержит символическую ссылку'; return $?; } path_components_safe "$ARCHIVE_DIR" || { die "$EXIT_VALIDATION" 'Каталог архива содержит символическую ссылку'; return $?; }
require_remove_privilege || return $? require_remove_privilege || return $?
index=$(resolve_named_client "$name") || return $? index=$(resolve_named_client "$name") || return $?
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Удалять можно только managed-клиента'; return $?; } [[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Удалять можно только managed-клиента'; return $?; }
key=${CLIENT_KEYS[$index]}; path=$(validate_client_config_path "$key" "$name" "$index") || return $? key=${CLIENT_KEYS[$index]}; path=$(validate_client_config_path "$key" "$name" "$index") || return $?
if [[ -n $expected_key && $expected_key != "$key" ]]; then
die "$EXIT_VALIDATION" 'Идентичность клиента изменилась до удаления'
return $?
fi
REMOVE_NAME=$name REMOVE_KEY=$key REMOVE_CONFIG_PATH=$path REMOVE_META_FILE=${META_FILE[$key]} REMOVE_NAME=$name REMOVE_KEY=$key REMOVE_CONFIG_PATH=$path REMOVE_META_FILE=${META_FILE[$key]}
REMOVE_ALLOWED=${DISK_ALLOWED[$key]} REMOVE_ALLOWED=${DISK_ALLOWED[$key]}
if [[ -z $flag ]] && ! remove_is_interactive; then if [[ -z $flag ]] && ! remove_is_interactive; then
@@ -2341,15 +2585,30 @@ remove_client_impl() {
TRANSACTION_FINALIZER=archive_remove_artifacts TRANSACTION_FINALIZER=archive_remove_artifacts
if run_transaction remove "$name" remove_peer_builder "$REMOVE_META_FILE"; then status=0; else status=$?; fi if run_transaction remove "$name" remove_peer_builder "$REMOVE_META_FILE"; then status=0; else status=$?; fi
if (( status == 0 )); then if (( status == 0 )); then
[[ -n ${REMOVE_ARCHIVE_PATH:-} ]] && REMOVE_ARCHIVE_RESULT=$REMOVE_ARCHIVE_PATH
REMOVE_ERROR_CATEGORY=success REMOVE_ERROR_CATEGORY=success
if ! remove_audit success; then printf 'Предупреждение: удаление выполнено, но не удалось записать аудит (частичный результат)\n' >&2; status=$EXIT_OPERATION; fi if ! remove_audit success; then printf 'Предупреждение: удаление выполнено, но не удалось записать аудит (частичный результат)\n' >&2; status=$EXIT_OPERATION; fi
elif { REMOVE_ERROR_CATEGORY=transaction_failed; ! remove_audit failed; }; then elif { REMOVE_ERROR_CATEGORY=transaction-failed; ! remove_audit failed; }; then
printf 'Предупреждение: операция удаления завершилась ошибкой, аудит не записан\n' >&2 printf 'Предупреждение: операция удаления завершилась ошибкой, аудит не записан\n' >&2
fi fi
unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH TRANSACTION_FINALIZER unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH TRANSACTION_FINALIZER
return "$status" return "$status"
} }
remove_client_impl() {
local name='' flag='' arg
if (($# > 0)); then name=$1; shift; fi
while (($#)); do
arg=$1
case $arg in
--yes) [[ -z $flag ]] || { die "$EXIT_USAGE" 'Флаг remove --yes указан повторно'; return $?; }; flag=--yes ;;
*) die "$EXIT_USAGE" 'Лишние аргументы remove'; return $? ;;
esac
shift
done
remove_client_core "$name" '' "$flag"
}
remove_client() { remove_client() {
local xtrace=0 status local xtrace=0 status
[[ $- == *x* ]] && xtrace=1; set +x [[ $- == *x* ]] && xtrace=1; set +x
@@ -2359,6 +2618,15 @@ remove_client() {
return "$status" return "$status"
} }
remove_client_exact() {
local name=$1 expected_key=$2 status xtrace=0
[[ $- == *x* ]] && xtrace=1
set +x
if remove_client_core "$name" "$expected_key" --yes; then status=0; else status=$?; fi
(( xtrace == 1 )) && set -x
return "$status"
}
main() { main() {
local status local status
if (( $# == 0 )); then if (( $# == 0 )); then