feat(tui): add safe client and backup workflows
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user