diff --git a/PLANS.md b/PLANS.md index 72f9feb..15ebb68 100644 --- a/PLANS.md +++ b/PLANS.md @@ -270,7 +270,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`. | M7 | DONE | Diagnostics engine and report | | M8 | DONE | Backup listing/restoration and audit log UX | | 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 | --- @@ -1691,6 +1691,20 @@ Use this template: - Suggested commit message: `feat(tui): add gum dashboard and read-only workflows` - 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 The MVP is complete only when: diff --git a/README.md b/README.md index 4880714..d804630 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ not inspect or modify host WireGuard state. ## M4 client creation `add NAME` creates a client transactionally. The optional forms are -`--mode full-tunnel|split-tunnel` and `--endpoint HOST`; unknown or extra +`--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, `.`, `_`, and `-` (without a leading `-`). Root is required for production use; 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; 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`, затем `PATH`; необходимые подкоманды проверяются до вывода интерфейса. Нужны одновременно интерактивные stdin и stdout. Escape и отмена возвращают на предыдущий экран или завершают меню без изменений. Доступны status, clients/show, -diagnose, log и backups; add/remove в M9 являются placeholder-действиями и не -выполняют мутаций. CLI работает независимо от `gum`; отсутствие или неполная +diagnose, log и backups. В M10 также доступны add/remove, создание backup и +restore через те же backend-функции, что и CLI; подтверждение и отмена остаются +обязательными для мутаций, а критический rollback завершает TUI с ненулевым кодом. +Для клиента сначала показываются безопасные детали; конфигурация и QR требуют +отдельного явного действия, при отсутствии `qrencode` показывается понятный +fallback. CLI работает независимо от `gum`; отсутствие или неполная возможность `gum` даёт код `69` только для TUI, а неинтерактивный запуск TUI — -код `64`. +код `64`. Восстановление требует ввода полного идентификатора выбранной копии; +несовпадение или отмена не изменяют состояние. ## M6 удаление клиента diff --git a/tests/mocks/bin/gum b/tests/mocks/bin/gum index f1029db..19a4c20 100755 --- a/tests/mocks/bin/gum +++ b/tests/mocks/bin/gum @@ -42,7 +42,7 @@ case $subcommand in fi case $subcommand in 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 ;; esac ;; diff --git a/tests/run.sh b/tests/run.sh index 9c8c704..8809884 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh" case $# in 0) ;; - 1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|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 ;; esac test_group=${1-} @@ -391,6 +391,10 @@ run_gum_tests() { prepare_m9_fixture() { 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" cp -- "${PROJECT_DIR}/tests/fixtures/wg0.conf" "${TEST_ROOT}/wg0.conf" write_valid_config "$config_file" @@ -413,8 +417,84 @@ prepare_m9_fixture() { : >"$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() { - 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 gum-resolution) run_gum_tests @@ -463,13 +543,13 @@ run_m9_tests() { printf 'Неизвестно\nНеизвестно\nНеизвестно\n' >"$script_file" set +e; tui_screen >/dev/null 2>&1; status=$?; set -e 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" set +e; output=$(tui_screen 2>&1); status=$?; set -e assert_equal 0 "$status" 'all read-only and placeholder routes return to menu' - assert_contains 'Действие «Добавить»' "$output" 'add placeholder is visible' - assert_contains 'Действие «Удалить»' "$output" 'remove placeholder is visible' - assert_equal 0 "$(grep -c '^gum pager$' "$TEST_MOCK_LOG" | awk '$1 >= 3 { print 0; next } { print 1 }')" 'diagnose/log/backups reach pager' + assert_contains 'gum input' "$(<"$TEST_MOCK_LOG")" 'add route reaches input wrapper' + assert_contains 'gum filter' "$(<"$TEST_MOCK_LOG")" 'remove route reaches filter wrapper' + 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" set +e; main >/dev/null 2>&1; status=$?; set -e 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 assert_failure "$status" 'unset test TTY is not accepted' ;; - tui-read-only) + tui-read-only) 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") begin_test 'TUI client selection uses stable identity and read-only backend' 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' 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) - 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 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' @@ -519,7 +599,7 @@ run_m9_tests() { long_name=$(printf 'n%.0s' {1..64}) CLIENT_NAMES=("$long_name" "$long_name") CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=) 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 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' @@ -531,6 +611,514 @@ run_m9_tests() { 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' ;; + 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) prepare_m9_fixture 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 assert_equal "$EXIT_USAGE" "$status" 'лишний аргумент remove отклонён' 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 assert_equal "$EXIT_USAGE" "$status" 'отсутствующее имя remove отклонено' 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 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 -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 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 @@ -3530,7 +4121,7 @@ else run_m8_tests done 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 run_m9_tests done diff --git a/wg-manager.sh b/wg-manager.sh index e3698e1..39ed2c5 100755 --- a/wg-manager.sh +++ b/wg-manager.sh @@ -54,7 +54,7 @@ print_usage() { Использование: wg-manager.sh help wg-manager.sh version - wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove 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 будут добавлены в следующих версиях. USAGE @@ -836,7 +836,7 @@ transaction_subshell_exit() { local status=$? transaction_cleanup 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 printf 'Предупреждение: не удалось защитить transaction result\n' >&2 fi @@ -986,7 +986,7 @@ run_transaction() { die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции' return $? 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 [[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; } set_runtime_paths || return $? @@ -1005,11 +1005,12 @@ run_transaction() { (( had_errexit == 1 )) && set -e || set +e if [[ -r $result_file ]]; then 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" fi rm -f -- "$result_file" TRANSACTION_BACKUP_PATH=${result_backup:-} + [[ -n ${result_finalizer:-} ]] && REMOVE_ARCHIVE_RESULT=$result_finalizer TRANSACTION_OUTCOME=${result_outcome:-failed-before-apply} if [[ $result_outcome == success ]]; then 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() { case $1 in 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 ;; 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 [[ -n ${path:-} ]] && rm -f -- "$path" done - unset ADD_NAME ADD_MODE ADD_ENDPOINT ADD_PRIVATE_KEY ADD_PUBLIC_KEY ADD_PSK ADD_META_STAGE ADD_CONFIG_STAGE ADD_KEY_STAGE ADD_PUB_STAGE ADD_PSK_STAGE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX + 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='' } 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 $?; } name=$1; shift load_config || return $? @@ -1314,11 +1315,22 @@ add_client_impl() { (( endpoint_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --endpoint указан повторно'; return $?; } (($# >= 2)) || { die "$EXIT_USAGE" 'Для --endpoint требуется значение'; return $?; } 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 $? ;; esac done validate_client_name "$name" || { 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 $?; } require_add_privilege || return $? 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 return "$EXIT_OPERATION" 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 local status if run_transaction add "$name" add_client_builder; then @@ -1376,7 +1388,7 @@ add_client_impl() { status=$? fi 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 return "$status" fi @@ -1448,6 +1460,8 @@ gum_header() { gum_command style --border double --padding '0 1' "$1"; } gum_choose() { gum_command choose "$@"; } gum_filter() { gum_command filter --placeholder 'Выберите клиента' "$@"; } 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_table() { gum_command table "$@"; } gum_pager() { gum_command pager "$@"; } @@ -1496,7 +1510,202 @@ tui_select_client() { fi done ((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() { @@ -1518,12 +1727,13 @@ tui_screen() { 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 case $choice in - Состояние) tui_page_command print_status || ((failures += 1)) ;; - Клиенты) tui_select_client || ((failures += 1)) ;; - Диагностика) tui_page_command diagnose || ((failures += 1)) ;; - Журнал) tui_page_command list_audit || ((failures += 1)) ;; - 'Резервные копии') tui_page_command list_backups || ((failures += 1)) ;; - Добавить|Удалить) printf 'Действие «%s» доступно через CLI в M9; изменения не выполняются.\n' "$choice" ;; + Состояние) if tui_run_action tui_page_command print_status; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;; + Клиенты) if tui_run_action tui_select_client; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;; + Диагностика) if tui_run_action tui_page_command diagnose; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;; + Журнал) if tui_run_action tui_page_command list_audit; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;; + 'Резервные копии') if tui_run_action tui_backup_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;; + Добавить) 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 ;; *) printf 'Неизвестный пункт TUI\n' >&2; ((failures += 1)) ;; esac @@ -1768,7 +1978,7 @@ restore_backup() { if (( status == 0 )); then write_audit_event restore "$name" success success >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Восстановление выполнено, но аудит не записан'; return "$EXIT_OPERATION"; } 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 cleanup_restore_snapshot 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 } -export_client_config() { - local name=$1 show=${2:-0} index key path status=0 +export_client_config_core() { + local name=$1 show=${2:-0} index key path status=0 expected_key=${3:-} [[ $show == 0 || $show == 1 ]] || { die "$EXIT_USAGE" 'Недопустимый режим экспорта конфигурации'; return $?; } load_client_model || return $? index=$(resolve_named_client "$name") || return $? [[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; } key=${CLIENT_KEYS[$index]} + [[ -z $expected_key || $expected_key == "$key" ]] || { die "$EXIT_VALIDATION" 'Идентичность клиента изменилась'; return $?; } path=$(validate_client_config_path "$key" "$name" "$index") || return $? if (( show == 0 )); then printf '%s\n' "$path"; return 0; fi if open_validated_client_config "$path"; then :; else @@ -2031,6 +2242,16 @@ export_client_config() { 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() { local name=$1 index load_client_model || return $? @@ -2038,12 +2259,13 @@ show_client() { print_client_details "$name" "$index" } -qr_client() { - local name=$1 index key path qr_path status +qr_client_core() { + local name=$1 expected_key=${2:-} index key path qr_path status load_client_model || return $? index=$(resolve_named_client "$name") || return $? [[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; } key=${CLIENT_KEYS[$index]} + [[ -z $expected_key || $expected_key == "$key" ]] || { die "$EXIT_VALIDATION" 'Идентичность клиента изменилась'; 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 [[ -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"; } } +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}"; } +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() { local stage=$1 target_key=${REMOVE_KEY:?} target_name=${REMOVE_NAME:?} local -a lines=() kept=() block=() @@ -2312,17 +2553,20 @@ confirm_remove() { remove_is_interactive() { [[ -t 0 && -t 1 ]]; } -remove_client_impl() { - local name=$1 flag=${2:-} index key path status answer - (($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы remove'; return $?; } +remove_client_core() { + local name=$1 expected_key=$2 flag=$3 index key path status answer + REMOVE_ARCHIVE_RESULT='' validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; } - [[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг remove --yes'; return $?; } load_client_model || return $? path_components_safe "$ARCHIVE_DIR" || { die "$EXIT_VALIDATION" 'Каталог архива содержит символическую ссылку'; return $?; } require_remove_privilege || return $? index=$(resolve_named_client "$name") || return $? [[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Удалять можно только managed-клиента'; return $?; } key=${CLIENT_KEYS[$index]}; path=$(validate_client_config_path "$key" "$name" "$index") || return $? + 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_ALLOWED=${DISK_ALLOWED[$key]} if [[ -z $flag ]] && ! remove_is_interactive; then @@ -2341,15 +2585,30 @@ remove_client_impl() { TRANSACTION_FINALIZER=archive_remove_artifacts if run_transaction remove "$name" remove_peer_builder "$REMOVE_META_FILE"; then status=0; else status=$?; fi if (( status == 0 )); then + [[ -n ${REMOVE_ARCHIVE_PATH:-} ]] && REMOVE_ARCHIVE_RESULT=$REMOVE_ARCHIVE_PATH REMOVE_ERROR_CATEGORY=success if ! remove_audit success; then printf 'Предупреждение: удаление выполнено, но не удалось записать аудит (частичный результат)\n' >&2; status=$EXIT_OPERATION; fi - elif { REMOVE_ERROR_CATEGORY=transaction_failed; ! remove_audit failed; }; then + elif { REMOVE_ERROR_CATEGORY=transaction-failed; ! remove_audit failed; }; then printf 'Предупреждение: операция удаления завершилась ошибкой, аудит не записан\n' >&2 fi unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH TRANSACTION_FINALIZER return "$status" } +remove_client_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() { local xtrace=0 status [[ $- == *x* ]] && xtrace=1; set +x @@ -2359,6 +2618,15 @@ remove_client() { 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() { local status if (( $# == 0 )); then