From 8b40b8cd65014ab86b16841e3d9f07cace494d88 Mon Sep 17 00:00:00 2001 From: sasa Date: Wed, 14 Oct 2026 21:56:18 +0300 Subject: [PATCH] feat(tui): add gum dashboard and read-only workflow --- PLANS.md | 16 +++- README.md | 12 +++ tests/mocks/bin/gum | 53 ++++++++++--- tests/run.sh | 187 +++++++++++++++++++++++++++++++++++++++++++- wg-manager.sh | 109 +++++++++++++++++++++++++- 5 files changed, 362 insertions(+), 15 deletions(-) diff --git a/PLANS.md b/PLANS.md index 0f6c2c6..72f9feb 100644 --- a/PLANS.md +++ b/PLANS.md @@ -269,7 +269,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`. | M6 | DONE | Safe client removal and archival | | M7 | DONE | Diagnostics engine and report | | M8 | DONE | Backup listing/restoration and audit log UX | -| M9 | NOT STARTED | TUI foundation, dashboard, and read-only screens | +| M9 | DONE | TUI foundation, dashboard, and read-only screens | | M10 | NOT STARTED | Complete TUI mutation workflows | | M11 | NOT STARTED | Hardening, documentation, and MVP acceptance | @@ -1677,6 +1677,20 @@ Use this template: - Suggested commit message: `feat(backup): add audited backup listing and restoration` - Next milestone: M9 +## 2026-10-14 — M9 — DONE + +- Implemented: validated fixed-order `gum` resolution and capability checks; isolated wrappers for style, choose, filter, input, confirm, table, pager, and spinner; interactive-terminal gating; a Russian dashboard and shallow read-only menu; backend-routed status, client list/detail, diagnostics, audit, and backup screens; stable client selection; normal Escape handling; and non-mutating add/remove placeholders. +- Coordinator: `gpt-5.6-sol` (`high`) +- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`) +- Repair cycles: 5 +- Sol review: replaced the worker's aliased M1-only coverage with real M9 implementation and tests; fixed production-directory temporary writes, ambiguous name/index selection, colliding display identities, literal dashboard newlines, English menu tokens, mock stdin consumption, cancellation false positives, TTY and gum-path cleanup, incomplete route/read-only evidence, 80-column labels, and fixture environment contamination; added regression coverage for every material finding. +- Verification: `bash -n wg-manager.sh tests/run.sh tests/mocks/bin/gum`, `bash tests/run.sh gum-resolution`, `bash tests/run.sh tui-routing`, `bash tests/run.sh tui-cancel`, `bash tests/run.sh tui-read-only`, `bash tests/run.sh tui-no-gum`, `bash tests/run.sh tui-non-tty`, `bash tests/run.sh`, `git diff --check`, full changed-file and secret-pattern review. +- Result: all required checks passed; gum-resolution/tui-routing/tui-cancel/tui-read-only/tui-no-gum/tui-non-tty passed 21/18/9/15/4/8 assertions, and the full regression suite passed 781 assertions. Optional ShellCheck and real-`gum` interactive smoke were `SKIP` because neither ShellCheck nor a usable `gum` binary is installed. +- Decisions: none +- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, and production terminal state were not accessed; M9 verification is fixture- and mock-based as required. Add/remove remain read-only placeholders until M10. +- Suggested commit message: `feat(tui): add gum dashboard and read-only workflows` +- Next milestone: M10 + # 8. Final completion rule The MVP is complete only when: diff --git a/README.md b/README.md index d4b8641..4880714 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,18 @@ for an operational read/export/QR failure. `config NAME --show` is the only inspection form that writes the complete client configuration to stdout; ordinary `show`, `config`, and `qr` output remains secret-safe. +## M9 TUI + +Запуск без аргументов или командой `tui` открывает read-only интерфейс `gum` в +терминале. `gum` разрешается в порядке `WG_MANAGER_GUM`, локального `bin/gum`, +затем `PATH`; необходимые подкоманды проверяются до вывода интерфейса. Нужны +одновременно интерактивные stdin и stdout. Escape и отмена возвращают на +предыдущий экран или завершают меню без изменений. Доступны status, clients/show, +diagnose, log и backups; add/remove в M9 являются placeholder-действиями и не +выполняют мутаций. CLI работает независимо от `gum`; отсутствие или неполная +возможность `gum` даёт код `69` только для TUI, а неинтерактивный запуск TUI — +код `64`. + ## M6 удаление клиента `remove NAME [--yes]` удаляет только подтверждённый managed-peer после повторной проверки точного публичного ключа, адресов и блока под lock. В интерактивном терминале без `--yes` показываются имя, адреса и только последние 8 символов публичного ключа; ответ, отличный от `y`, отменяет операцию без изменений. В неинтерактивном режиме `--yes` обязателен. diff --git a/tests/mocks/bin/gum b/tests/mocks/bin/gum index 5c9ca66..f1029db 100755 --- a/tests/mocks/bin/gum +++ b/tests/mocks/bin/gum @@ -2,14 +2,49 @@ set -Eeuo pipefail command_name=${0##*/} : "${TEST_MOCK_LOG:?TEST_MOCK_LOG must identify the isolated test log}" -allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-} -printf '%q' "$command_name" >>"$TEST_MOCK_LOG"; for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done; printf '\n' >>"$TEST_MOCK_LOG" -if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then - printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2 - exit 99 -fi -if [[ $# == 0 && "$allow_empty" == "$command_name" ]]; then +printf '%q' "$command_name" >>"$TEST_MOCK_LOG" +for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done +printf '\n' >>"$TEST_MOCK_LOG" +if (($# == 1)) && [[ $1 == --version ]]; then printf 'gum version 0.14.0\n'; exit 0; fi +subcommand=${1:-} +if [[ $subcommand == --help || $subcommand == '' ]]; then exit 0; fi +if (($# >= 2)) && [[ ${!#} == --help ]]; then + [[ ${GUM_FAIL_CAPABILITY:-} == "$subcommand" ]] && exit 98 exit 0 fi -printf 'Unexpected mock invocation: %s received arguments\n' "$command_name" >&2 -exit 99 +case $subcommand in + style) + [[ $# -gt 1 ]] && [[ ${!#} != *'\n'* ]] || exit 98 + if [[ -n ${GUM_STYLE_CAPTURE:-} ]]; then printf '%s' "${!#}" >"$GUM_STYLE_CAPTURE"; fi + printf '%s\n' "${!#}" + exit 0 + ;; + table|pager) + if [[ -n ${GUM_PAGER_CAPTURE:-} ]]; then cat >"$GUM_PAGER_CAPTURE"; else cat >/dev/null; fi + exit 0 + ;; + spin) exit 0 ;; + choose|filter|input|confirm) + if [[ -n ${GUM_CHOICES_CAPTURE:-} && ( $subcommand == choose || $subcommand == filter ) ]]; then + : >"$GUM_CHOICES_CAPTURE" + if [[ $subcommand == filter ]]; then + shift 3 + else + shift + fi + printf '%s\n' "$@" >>"$GUM_CHOICES_CAPTURE" + fi + script=${GUM_SCRIPT_FILE:-}; answer='' + if [[ -n $script && -f $script ]]; then + IFS= read -r answer <"$script" || answer='' + tail -n +2 -- "$script" >"${script}.next" + mv -- "${script}.next" "$script" + fi + case $subcommand in + confirm) [[ $answer == y || $answer == Y ]] || exit 1; exit 0 ;; + input) printf '%s\n' "$answer"; exit 0 ;; + *) [[ -n $answer ]] && printf '%s\n' "$answer" || exit 1; exit 0 ;; + esac + ;; + *) printf 'Unexpected gum subcommand: %s\n' "$subcommand" >&2; exit 99 ;; +esac diff --git a/tests/run.sh b/tests/run.sh index f950442..9c8c704 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh" case $# in 0) ;; - 1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes) ;; *) 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) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac test_group=${1-} @@ -385,6 +385,184 @@ run_gum_tests() { set -e assert_equal "$EXIT_DEPENDENCY" "$status" 'ошибка WG_MANAGER_GUM' assert_contains 'WG_MANAGER_GUM' "$output" 'сообщение WG_MANAGER_GUM' + rmdir -- "${TEST_ROOT}/bin/gum" + rmdir -- "${TEST_ROOT}/directory-gum" +} + +prepare_m9_fixture() { + local config_file="${TEST_ROOT}/m9.conf" fixture_error + mkdir -p -- "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" + cp -- "${PROJECT_DIR}/tests/fixtures/wg0.conf" "${TEST_ROOT}/wg0.conf" + write_valid_config "$config_file" + export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/manager.lock" WG_MANAGER_GUM="${PROJECT_DIR}/tests/mocks/bin/gum" WG_MANAGER_TEST_MODE=1 WG_MANAGER_TEST_TTY=both GUM_STYLE_CAPTURE="${TEST_ROOT}/gum-style.txt" GUM_PAGER_CAPTURE="${TEST_ROOT}/gum-pager.txt" GUM_CHOICES_CAPTURE="${TEST_ROOT}/gum-choices.txt" + mkdir -p -- "${TEST_ROOT}/archive" + printf '1\n' >"${TEST_ROOT}/ipv4-forward" + printf '1\n' >"${TEST_ROOT}/ipv6-forward" + export WG_MANAGER_IPV4_FORWARD_FILE="${TEST_ROOT}/ipv4-forward" WG_MANAGER_IPV6_FORWARD_FILE="${TEST_ROOT}/ipv6-forward" + printf '[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAddress = 10.0.0.2/32\n' >"${TEST_ROOT}/clients/alice.conf" + chmod 600 -- "${TEST_ROOT}/clients/alice.conf" + printf 'NAME=alice\nPUBLIC_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nIPV4=192.0.2.2\nCREATED_AT=1700000000\nCONFIG_PATH=%s\nCLIENT_MODE=full-tunnel\nMANAGED=1\n' "${TEST_ROOT}/clients/alice.conf" >"${TEST_ROOT}/metadata/alice.meta" + chmod 600 -- "${TEST_ROOT}/metadata/alice.meta" + load_config >/dev/null + printf 'timestamp=2023-11-14T22:13:20Z actor_uid=%s operation=tui target=fixture client=fixture key_suffix= outcome=success error=read_only\n' "$EUID" >"${TEST_ROOT}/logs/wg-manager.log" + chmod 600 -- "${TEST_ROOT}/logs/wg-manager.log" + if ! fixture_error=$(create_backup "$WG_CONF" tui fixture 2>&1); then + printf 'Ошибка тестовой фикстуры: не удалось создать резервную копию: %s\n' "$fixture_error" >&2 + return 1 + fi + : >"$TEST_MOCK_LOG" +} + +run_m9_tests() { + local output status script_file tty_mode pager_count snapshot_before lock_status long_name + case $test_group in + gum-resolution) + run_gum_tests + prepare_m9_fixture + begin_test 'gum capability probes accept the scripted mock' + output=$(resolve_gum) + assert_contains 'tests/mocks/bin/gum' "$output" 'gum mock resolved' + validate_gum_capabilities "$output" >/dev/null + assert_success "$?" 'gum capabilities' + export GUM_FAIL_CAPABILITY=filter + set +e; output=$(validate_gum_capabilities "$output" 2>&1); status=$?; set -e + assert_equal "$EXIT_DEPENDENCY" "$status" 'missing gum capability status' + assert_contains 'filter' "$output" 'missing capability diagnostic names subcommand' + unset GUM_FAIL_CAPABILITY + printf 'Выбор\nКлиент\ny\ny\n' >"${TEST_ROOT}/gum-wrapper-script" + export GUM_SCRIPT_FILE="${TEST_ROOT}/gum-wrapper-script" + : >"$TEST_MOCK_LOG" + gum_header 'Заголовок' >/dev/null + gum_choose 'Клиент' 'Выход' >/dev/null + gum_filter 'Клиент' >/dev/null + gum_input --placeholder 'Имя' >/dev/null + gum_confirm 'Продолжить' >/dev/null + printf 'таблица\n' | gum_table >/dev/null + printf 'страница\n' | gum_pager >/dev/null + gum_spin --title 'Ожидание' >/dev/null + begin_test 'all gum wrappers keep their raw invocation details isolated' + for capability in style choose filter input confirm table pager spin; do + assert_contains "gum $capability" "$(<"$TEST_MOCK_LOG")" "обёртка gum $capability" + done + ;; + tui-routing) + prepare_m9_fixture + script_file="${TEST_ROOT}/gum-script"; printf 'Состояние\nВыход\n' >"$script_file"; export GUM_SCRIPT_FILE="$script_file" + begin_test 'TUI routes status through the dashboard loop' + set +e; output=$(tui_screen 2>&1); status=$?; set -e + assert_equal 0 "$status" 'TUI status route exits cleanly' + assert_contains 'gum choose' "$(<"$TEST_MOCK_LOG")" 'menu wrapper invocation recorded' + assert_contains 'gum pager' "$(<"$TEST_MOCK_LOG")" 'pager wrapper invocation recorded' + assert_contains 'WireGuard Manager — wg0' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard interface' + assert_contains 'Состояние:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard runtime state' + assert_contains 'Управляемых:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard managed count' + assert_contains 'Активны недавно:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard active count' + assert_contains 'Предупреждения:' "$(<"$GUM_STYLE_CAPTURE")" 'dashboard warning count' + assert_equal 0 "$(grep -c '\\\\n' "$GUM_STYLE_CAPTURE" || true)" 'dashboard has real line breaks' + assert_equal 0 "$(awk 'length($0) > 80 { count += 1 } END { print count + 0 }' "$GUM_STYLE_CAPTURE")" 'dashboard lines fit 80 columns' + printf 'Неизвестно\nНеизвестно\nНеизвестно\n' >"$script_file" + set +e; tui_screen >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_OPERATION" "$status" 'three route failures are bounded' + printf 'Диагностика\nЖурнал\nРезервные копии\nДобавить\nУдалить\nВыход\n' >"$script_file" + : >"$TEST_MOCK_LOG" + set +e; output=$(tui_screen 2>&1); status=$?; set -e + assert_equal 0 "$status" 'all read-only and placeholder routes return to menu' + assert_contains 'Действие «Добавить»' "$output" 'add placeholder is visible' + assert_contains 'Действие «Удалить»' "$output" 'remove placeholder is visible' + assert_equal 0 "$(grep -c '^gum pager$' "$TEST_MOCK_LOG" | awk '$1 >= 3 { print 0; next } { print 1 }')" 'diagnose/log/backups reach pager' + printf 'Выход\n' >"$script_file" + set +e; main >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'zero-argument main enters TUI' + printf 'Выход\n' >"$script_file" + set +e; main tui >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'explicit tui command exits cleanly' + assert_equal '' "${TUI_GUM_PATH:-}" 'explicit tui clears resolved gum path' + ;; + tui-cancel) + prepare_m9_fixture + script_file="${TEST_ROOT}/gum-script"; : >"$script_file"; export GUM_SCRIPT_FILE="$script_file" + begin_test 'TUI cancellation returns normally without mutation' + set +e; tui_screen >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'menu cancellation' + assert_equal '' "$(grep -E 'wg (syncconf|quick)|wg-quick' "$TEST_MOCK_LOG" || true)" 'cancellation has no mutation command' + printf 'Клиенты\n\nВыход\n' >"$script_file" + : >"$TEST_MOCK_LOG" + set +e; tui_screen >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'client filter cancellation returns to menu' + assert_equal 2 "$(grep '^gum choose' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation returns to main menu' + assert_equal 1 "$(grep '^gum filter' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation invokes one filter' + assert_equal 1 "$(grep '^gum pager' "$TEST_MOCK_LOG" | grep -vc -- '--help' || true)" 'filter cancellation only pages the client list' + assert_equal 0 "$(grep -c 'TUI завершён\|Неизвестный пункт' "$TEST_MOCK_LOG" || true)" 'filter cancellation is not a route failure' + assert_equal '' "${TUI_GUM_PATH:-}" 'TUI gum path is cleared after cancellation' + unset WG_MANAGER_TEST_TTY + set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e + assert_failure "$status" 'unset test TTY is not accepted' + ;; + tui-read-only) + prepare_m9_fixture + script_file="${TEST_ROOT}/gum-script"; printf 'Клиенты\nalice — BBBBBBBB\nВыход\n' >"$script_file"; export GUM_SCRIPT_FILE="$script_file" + snapshot_before=$(find "${TEST_ROOT}/backups" "${TEST_ROOT}/logs" "${TEST_ROOT}/archive" "${TEST_ROOT}/clients" "${TEST_ROOT}/metadata" -mindepth 1 -maxdepth 2 -printf '%P %y %m %s\n' | LC_ALL=C sort; sha256sum "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta"; stat -c '%a:%u:%g:%h' "$WG_CONF" "$WG_MANAGER_CONFIG" "${TEST_ROOT}/clients/alice.conf" "${TEST_ROOT}/metadata/alice.meta") + begin_test 'TUI client selection uses stable identity and read-only backend' + set +e; tui_screen >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'client route exits cleanly' + assert_contains 'gum filter' "$(<"$TEST_MOCK_LOG")" 'filter wrapper invocation recorded' + pager_count=$(grep -c '^gum pager$' "$TEST_MOCK_LOG" || true) + ((pager_count >= 1)) && ((TEST_ASSERTIONS += 1)) || record_failure 'client list is paged before selection' + assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)' "$TEST_MOCK_LOG" || true)" 'read-only screen has no mutation calls' + CLIENT_NAMES=(same same) CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=) + CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32) CLIENT_ENDPOINTS=(- -) CLIENT_HANDSHAKES=(0 0) CLIENT_RX=(0 0) CLIENT_TX=(0 0) CLIENT_STATES=(unmanaged unmanaged) CLIENT_ORIGINS=(unmanaged unmanaged) + printf 'same — ABCDEFGH #2\n' >"$script_file" + set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'duplicate display names select the offered stable index' + assert_contains 'ABCDEFGH222222222222222222222222222222222=' "$(<"$GUM_PAGER_CAPTURE")" 'selected duplicate identity renders the second full key' + assert_equal 0 "$(grep -c 'ABCDEFGH111111111111111111111111111111111=' "$GUM_PAGER_CAPTURE" || true)" 'selected detail does not render the first key' + long_name=$(printf 'n%.0s' {1..64}) + CLIENT_NAMES=("$long_name" "$long_name") CLIENT_KEYS=(ABCDEFGH111111111111111111111111111111111= ABCDEFGH222222222222222222222222222222222=) + CLIENT_ADDRESSES=(192.0.2.2/32 192.0.2.2/32) + printf '%s\n' "${long_name:0:60} — ABCDEFGH #2" >"$script_file" + set +e; tui_select_client >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'long client names remain selectable' + assert_equal 0 "$(awk 'length($0) > 80 { bad += 1 } END { print bad + 0 }' "$GUM_CHOICES_CAPTURE")" 'offered client labels fit 80 columns' + assert_contains 'ABCDEFGH222222222222222222222222222222222=' "$(<"$GUM_PAGER_CAPTURE")" 'long-name collision keeps the selected identity' + assert_equal "$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")" 'read-only files, modes, and directory entries unchanged' + assert_equal '' "$(grep -E 'syncconf|wg-quick (up|down|save)|ip (add|del|replace)|systemctl (start|stop|restart)|nft (add|delete)|iptables -.*-(A|D|I)' "$TEST_MOCK_LOG" || true)" 'read-only command log has no mutation' + assert_equal 0 "$(find "${WG_CONF%/*}" -maxdepth 1 -name '.wg-manager-tui.*' -print | wc -l)" 'read-only pages create no config-directory temporary files' + if [[ -e $WG_MANAGER_LOCK_FILE ]]; then lock_status=1; else lock_status=0; fi + assert_equal 0 "$lock_status" 'read-only screens do not create a mutation lock' + assert_equal 0 "$(grep -hE 'AAAAAAAAAAAAAAAA|PresharedKey' "$GUM_PAGER_CAPTURE" "$GUM_STYLE_CAPTURE" "$TEST_MOCK_LOG" | wc -l)" 'read-only TUI output has no secrets' + ;; + tui-no-gum) + prepare_m9_fixture + export WG_MANAGER_GUM="${TEST_ROOT}/missing-gum" + begin_test 'TUI without gum reports dependency while CLI remains independent' + set +e; output=$(tui_screen 2>&1); status=$?; set -e + assert_equal "$EXIT_DEPENDENCY" "$status" 'missing gum status' + assert_contains 'WG_MANAGER_GUM' "$output" 'missing gum diagnostic' + assert_equal '' "${TUI_GUM_PATH:-}" 'gum path is cleared after dependency failure' + set +e; output=$(main status 2>&1); status=$?; set -e + assert_equal 0 "$status" 'CLI status remains independent of gum' + ;; + tui-non-tty) + prepare_m9_fixture + unset WG_MANAGER_TEST_TTY + begin_test 'TUI refuses noninteractive input before gum output' + set +e; output=$(tui_screen 2>&1); status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'non-TTY status' + assert_contains 'интерактивный терминал' "$output" 'non-TTY diagnostic' + assert_equal '' "$(<"$TEST_MOCK_LOG")" 'non-TTY has no gum invocation' + for tty_mode in stdin stdout none; do + export WG_MANAGER_TEST_TTY="$tty_mode" + set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e + assert_failure "$status" "TTY режим ${tty_mode} отклонён" + done + export WG_MANAGER_TEST_TTY=both + set +e; tui_is_terminal >/dev/null 2>&1; status=$?; set -e + assert_equal 0 "$status" 'оба потока TTY принимаются в изолированном режиме' + set +e; main tui extra >/dev/null 2>&1; status=$?; set -e + assert_equal "$EXIT_USAGE" "$status" 'tui extra argument is usage' + ;; + esac + unset GUM_SCRIPT_FILE WG_MANAGER_GUM WG_MANAGER_TEST_TTY WG_MANAGER_IPV4_FORWARD_FILE WG_MANAGER_IPV6_FORWARD_FILE GUM_STYLE_CAPTURE GUM_PAGER_CAPTURE GUM_CHOICES_CAPTURE GUM_FAIL_CAPABILITY } run_dependency_tests() { @@ -3324,6 +3502,8 @@ elif [[ $test_group == add-client || $test_group == address-allocation || $test_ run_m4_tests elif [[ $test_group == remove-client || $test_group == remove-cancel || $test_group == remove-exact-block || $test_group == remove-rollback || $test_group == archive-client || $test_group == archive-path-safety || $test_group == remove-preserves-neighbors ]]; then run_m6_tests +elif [[ $test_group == gum-resolution || $test_group == tui-routing || $test_group == tui-cancel || $test_group == tui-read-only || $test_group == tui-no-gum || $test_group == tui-non-tty ]]; then + run_m9_tests elif [[ $test_group == audit || $test_group == audit-no-secrets || $test_group == backup-list || $test_group == restore || $test_group == restore-invalid || $test_group == restore-rollback || $test_group == backup-path-safety || $test_group == runtime-file-modes ]]; then run_m8_tests elif [[ $test_group == diagnostics || $test_group == diagnostics-systemd || $test_group == diagnostics-firewall || $test_group == diagnostics-forwarding || $test_group == diagnostics-runtime-diff || $test_group == diagnostics-read-only || $test_group == diagnostics-exit-codes ]]; then @@ -3350,6 +3530,11 @@ else run_m8_tests done unset m8_group + for m9_group in gum-resolution tui-routing tui-cancel tui-read-only tui-no-gum tui-non-tty; do + test_group=$m9_group + run_m9_tests + done + unset m9_group test_group='' fi diff --git a/wg-manager.sh b/wg-manager.sh index 9142e7f..e3698e1 100755 --- a/wg-manager.sh +++ b/wg-manager.sh @@ -1433,6 +1433,106 @@ resolve_gum() { optional_command gum || { die "$EXIT_DEPENDENCY" 'Не найден gum для TUI'; return $?; } } +validate_gum_capabilities() { + local gum_path=$1 capability + local -a capabilities=(style choose filter input confirm table pager spin) + for capability in "${capabilities[@]}"; do + "$gum_path" "$capability" --help >/dev/null 2>&1 || { + die "$EXIT_DEPENDENCY" "gum не поддерживает команду: ${capability}" + return $? + } + done +} +gum_command() { local gum_path=${TUI_GUM_PATH:-}; [[ -n $gum_path ]] || gum_path=$(resolve_gum) || return $?; "$gum_path" "$@"; } +gum_header() { gum_command style --border double --padding '0 1' "$1"; } +gum_choose() { gum_command choose "$@"; } +gum_filter() { gum_command filter --placeholder 'Выберите клиента' "$@"; } +gum_input() { gum_command input "$@"; } +gum_confirm() { gum_command confirm "$@"; } +gum_table() { gum_command table "$@"; } +gum_pager() { gum_command pager "$@"; } +gum_spin() { gum_command spin "$@"; } +tui_is_terminal() { + if [[ ${WG_MANAGER_TEST_TTY+x} && ${WG_MANAGER_TEST_TTY} == both ]] && transaction_test_hooks_enabled; then return 0; fi + [[ -t 0 && -t 1 ]] +} +tui_page_command() { + local command_name=$1 output backend_status pager_status + shift + if output=$("$command_name" "$@" 2>&1); then backend_status=0; else backend_status=$?; fi + if printf '%s' "$output" | gum_pager; then pager_status=0; else pager_status=$?; fi + ((backend_status != 0)) && return "$backend_status" + return "$pager_status" +} + +tui_select_client() { + local choice index label base previous ordinal duplicate + local -a choices=() choice_indices=() + tui_page_command print_list || return $? + for index in "${!CLIENT_KEYS[@]}"; do + base="${CLIENT_NAMES[$index]} — ${CLIENT_KEYS[$index]:0:8}" + ordinal=1; duplicate=0 + for previous in "${!CLIENT_KEYS[@]}"; do + [[ $previous -ge $index ]] && break + if [[ "${CLIENT_NAMES[$previous]} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then + ((ordinal += 1)); duplicate=1 + fi + done + for previous in "${!CLIENT_KEYS[@]}"; do + ((previous > index)) || continue + if [[ "${CLIENT_NAMES[$previous]} — ${CLIENT_KEYS[$previous]:0:8}" == "$base" ]]; then duplicate=1; break; fi + done + if ((duplicate == 1)); then label="${CLIENT_NAMES[$index]:0:60} — ${CLIENT_KEYS[$index]:0:8} #${ordinal}"; else label=$base; fi + choices+=("$label") + choice_indices+=("$index") + done + ((${#choices[@]} > 0)) || { printf 'Клиенты отсутствуют\n'; return 0; } + choice=$(gum_filter "${choices[@]}") || return 0 + index=-1 + for previous in "${!choices[@]}"; do + if [[ ${choices[$previous]} == "$choice" ]]; then + if ((index >= 0)); then return "$EXIT_VALIDATION"; fi + index=${choice_indices[$previous]} + fi + done + ((index >= 0)) || return "$EXIT_VALIDATION" + tui_page_command print_client_details "${CLIENT_NAMES[$index]}" "$index" +} + +tui_screen() { + local choice failures=0 index managed_count=0 active_count=0 warning_count=0 runtime_text dashboard status + local -a menu_labels=('Состояние' 'Клиенты' 'Диагностика' 'Журнал' 'Резервные копии' 'Добавить' 'Удалить' 'Выход') + unset TUI_GUM_PATH + tui_is_terminal || { die "$EXIT_USAGE" 'TUI требует интерактивный терминал'; return $?; } + if TUI_GUM_PATH=$(resolve_gum); then :; else status=$?; unset TUI_GUM_PATH; return "$status"; fi + validate_gum_capabilities "$TUI_GUM_PATH" || { unset TUI_GUM_PATH; return $?; } + load_client_model || { unset TUI_GUM_PATH; return $?; } + for index in "${!CLIENT_ORIGINS[@]}"; do + if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then ((managed_count += 1)); fi + if [[ ${CLIENT_STATES[$index]} == active ]]; then ((active_count += 1)); fi + done + ((RUNTIME_AVAILABLE == 1)) || ((warning_count += 1)) + if ((RUNTIME_AVAILABLE == 1)); then runtime_text='доступен'; else runtime_text='недоступен'; fi + while ((failures < 3)); do + printf -v dashboard 'WireGuard Manager — %s\nСостояние: %s\nКлиентов: %d Управляемых: %d\nАктивны недавно: %d Предупреждения: %d' "$WG_INTERFACE" "$runtime_text" "${#CLIENT_KEYS[@]}" "$managed_count" "$active_count" "$warning_count" + if gum_header "$dashboard"; then :; else status=$?; unset TUI_GUM_PATH; return "$status"; fi + if choice=$(gum_choose "${menu_labels[@]}"); then :; else unset TUI_GUM_PATH; return 0; fi + case $choice in + Состояние) tui_page_command print_status || ((failures += 1)) ;; + Клиенты) tui_select_client || ((failures += 1)) ;; + Диагностика) tui_page_command diagnose || ((failures += 1)) ;; + Журнал) tui_page_command list_audit || ((failures += 1)) ;; + 'Резервные копии') tui_page_command list_backups || ((failures += 1)) ;; + Добавить|Удалить) printf 'Действие «%s» доступно через CLI в M9; изменения не выполняются.\n' "$choice" ;; + Выход|'') unset TUI_GUM_PATH; return 0 ;; + *) printf 'Неизвестный пункт TUI\n' >&2; ((failures += 1)) ;; + esac + done + unset TUI_GUM_PATH + die "$EXIT_OPERATION" 'TUI завершён после повторных ошибок' + return $? +} + print_version() { printf 'WireGuard Manager %s\n' "$WG_MANAGER_VERSION"; } run_placeholder() { load_config || return $?; die "$EXIT_OPERATION" "Команда «$1» пока не реализована"; } manual_backup() { @@ -2262,9 +2362,9 @@ remove_client() { main() { local status if (( $# == 0 )); then - if die "$EXIT_USAGE" 'Ожидается команда'; then status=0; else status=$?; fi - print_usage >&2 - return "$status" + load_config || return $? + tui_screen + return $? fi if [[ $1 == add ]]; then shift @@ -2323,7 +2423,8 @@ main() { remove_client "$@" ;; backup) manual_backup ;; backups) list_backups ;; - logs|tui) run_placeholder "$1" ;; + tui) (($# == 1)) || { die "$EXIT_USAGE" 'Команда tui не принимает аргументы'; return $?; }; load_config || return $?; tui_screen ;; + logs) run_placeholder "$1" ;; *) if die "$EXIT_USAGE" "неизвестная команда: $1"; then status=0; else status=$?; fi print_usage >&2