Compare commits
4 Commits
72c0d21977
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b0cfad98fd | |||
| a4f530d7a5 | |||
| 06c7171e91 | |||
| 8b40b8cd65 |
@@ -258,20 +258,20 @@ These decisions are fixed for the MVP unless the user explicitly changes them:
|
||||
|
||||
Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
|
||||
|
||||
| Milestone | Status | Outcome |
|
||||
| --------- | ----------- | ---------------------------------------------------- |
|
||||
| M0 | DONE | Test harness and safe project skeleton |
|
||||
| M1 | DONE | Runtime foundation, CLI shell, and config loading |
|
||||
| M2 | DONE | WireGuard parser, client model, `status`, and `list` |
|
||||
| M3 | DONE | Locking, backups, atomic apply, and rollback engine |
|
||||
| M4 | DONE | Safe client creation |
|
||||
| M5 | DONE | Client detail, config export, and QR display |
|
||||
| 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 |
|
||||
| M10 | NOT STARTED | Complete TUI mutation workflows |
|
||||
| M11 | NOT STARTED | Hardening, documentation, and MVP acceptance |
|
||||
| Milestone | Status | Outcome |
|
||||
| --------- | ------ | ---------------------------------------------------- |
|
||||
| M0 | DONE | Test harness and safe project skeleton |
|
||||
| M1 | DONE | Runtime foundation, CLI shell, and config loading |
|
||||
| M2 | DONE | WireGuard parser, client model, `status`, and `list` |
|
||||
| M3 | DONE | Locking, backups, atomic apply, and rollback engine |
|
||||
| M4 | DONE | Safe client creation |
|
||||
| M5 | DONE | Client detail, config export, and QR display |
|
||||
| M6 | DONE | Safe client removal and archival |
|
||||
| M7 | DONE | Diagnostics engine and report |
|
||||
| M8 | DONE | Backup listing/restoration and audit log UX |
|
||||
| M9 | DONE | TUI foundation, dashboard, and read-only screens |
|
||||
| M10 | DONE | Complete TUI mutation workflows |
|
||||
| M11 | DONE | Hardening, documentation, and MVP acceptance |
|
||||
|
||||
---
|
||||
|
||||
@@ -1677,6 +1677,48 @@ 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
|
||||
|
||||
## 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
|
||||
|
||||
## 2026-10-15 — M11 — DONE
|
||||
|
||||
- Implemented: complete MVP/AGENTS evidence mapping; mock-only end-to-end coverage for fresh, managed, mixed, stopped, malformed, add/inspect/QR fallback/remove/restore, successful rollback, failed rollback, and missing optional capabilities; repository/lifecycle secret scans; host-write and host-command-bypass guards; symlink hardening coverage; an early Bash 4.3 guard; a source-safe `add-user.sh` compatibility wrapper; and a cohesive Russian operations README with the explicitly deferred disposable deployment checklist.
|
||||
- Coordinator: `gpt-5.6-sol` (`high`)
|
||||
- Subagents: `milestone_worker` / `milestone_repairer`, `gpt-5.6-luna` (`medium`)
|
||||
- Repair cycles: 4
|
||||
- Sol review: replaced shallow acceptance aliases with real shared-backend lifecycle scenarios; added exact add/remove/restore and rollback evidence; strengthened secret, host-write, command-resolution, path, symlink, permission, and wrapper-source-safety checks; reconciled help, Bash-version, dependency, restore-confirmation, and deployment documentation with observable behavior; no material findings remained after the fourth repair.
|
||||
- Verification: `bash -n wg-manager.sh`, `bash -n add-user.sh`, `bash -n tests/run.sh`, `bash -n tests/helpers.sh`, `bash tests/run.sh no-secret-logs`, `bash tests/run.sh path-safety`, `bash tests/run.sh symlink-safety`, `bash tests/run.sh no-host-writes`, `bash tests/run.sh no-host-command-bypass`, `bash tests/run.sh rollback`, `bash tests/run.sh m11-e2e`, `bash tests/run.sh secret-scan`, `bash tests/run.sh`, `git diff --check`, `git status --short`, executable-mode and full-diff secret review.
|
||||
- Result: all required automated and repository checks passed; the full regression suite passed 1051 assertions, and focused no-secret-logs/path-safety/symlink-safety/no-host-writes/no-host-command-bypass/rollback/M11-E2E/secret-scan suites passed 41/31/4/8/18/65/60/11 assertions. Optional ShellCheck was `SKIP` because ShellCheck is unavailable; the real-`gum` smoke test and deployment-server checklist remain explicitly deferred.
|
||||
- Decisions: none
|
||||
- Known limitations: real WireGuard, `/etc/wireguard`, networking, systemd, firewall, routes, sysctl, journal, kernel tunnel behavior, and deployment-server state were not accessed; all M11 verification used fixtures and deny-by-default mocks and therefore does not prove live-server compatibility.
|
||||
- Suggested commit message: `docs: finalize WireGuard Manager MVP and operations guide`
|
||||
- Next milestone: none; MVP acceptance sequence M0–M11 is complete.
|
||||
|
||||
# 8. Final completion rule
|
||||
|
||||
The MVP is complete only when:
|
||||
|
||||
@@ -1,100 +1,225 @@
|
||||
# wireguard-scripts
|
||||
|
||||
## M1 runtime foundation
|
||||
Безопасный копируемый Bash-инструмент для управления одним WireGuard-интерфейсом
|
||||
на Linux. Скрипт не устанавливается в `PATH`, не запускает установщики и не
|
||||
изменяет систему вне явно настроенных путей. Операции изменения выполняются под
|
||||
`flock`, с резервной копией, атомарной заменой, проверкой runtime и откатом.
|
||||
|
||||
The current entry point provides dependency-free `help` and `version` commands. The
|
||||
planned operational commands are recognized but remain unavailable until later
|
||||
milestones.
|
||||
## Поддерживаемая среда и ограничения
|
||||
|
||||
Runtime locations default to the project directory and can be redirected for an
|
||||
isolated run with absolute paths using these environment variables:
|
||||
Требуются Linux, Bash 4.3+, `wg`, `wg-quick`, `ip`, `flock`, стандартные
|
||||
coreutils и util-linux. Минимальная заявленная архитектура — x86_64. Другие
|
||||
архитектуры не считаются протестированными; `gum` и `qrencode` должны
|
||||
соответствовать архитектуре самостоятельно установленного оператором бинарника.
|
||||
|
||||
Обязательные capabilities: `bash`, `wg`, `wg-quick`, `ip`, `flock`, `awk`,
|
||||
`grep`, `sed`, `sort`, `stat`, `mktemp`, `sha256sum`, `cp`, `mv`, `chmod` и
|
||||
прочие обычные coreutils, используемые системой. Необязательные: `gum` (TUI),
|
||||
`qrencode` (QR), `systemctl`/`journalctl` (systemd), `ss` (UDP), `nft` и
|
||||
`iptables` (наблюдение firewall). Отсутствие optional capability даёт
|
||||
`SKIP`/fallback, а не скрытый вызов host-команды.
|
||||
|
||||
Проект можно копировать как каталог:
|
||||
|
||||
```text
|
||||
wireguard-scripts/
|
||||
├── wg-manager.sh
|
||||
├── wg-manager.conf.example
|
||||
├── add-user.sh
|
||||
├── bin/gum # optional, если оператор его положил сюда
|
||||
├── tests/
|
||||
│ ├── fixtures/ # placeholder-only fixtures
|
||||
│ └── mocks/bin/ # deny-by-default test commands
|
||||
├── backups/ logs/ state/ # runtime paths, создаются при настройке
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Настройка
|
||||
|
||||
Скопируйте `wg-manager.conf.example` в `wg-manager.conf`, замените только
|
||||
placeholder-значения и задайте владельца root и режим `0600`. Все пути должны
|
||||
быть абсолютными, без symlink-компонентов, а каталоги client/metadata/backup/
|
||||
archive/log — `0700`; конфигурации, metadata, key и log — `0600`.
|
||||
|
||||
| Ключ | Назначение и ограничения |
|
||||
|---|---|
|
||||
| `WG_INTERFACE` | имя интерфейса, например `wg0` |
|
||||
| `WG_CONF` | абсолютный серверный конфиг |
|
||||
| `CLIENT_DIR` | каталог клиентских `.conf/.key/.pub/.psk` |
|
||||
| `SERVER_ENDPOINT` | проверяемое имя/IP сервера без порта |
|
||||
| `SERVER_PORT` | UDP-порт 1..65535 |
|
||||
| `SERVER_VPN_IP` | адрес сервера IPv4 |
|
||||
| `CLIENT_DNS` | непустой comma-separated список IPv4/IPv6 |
|
||||
| `VPN_SUBNET_PREFIX` | IPv4 prefix из трёх октетов, например `192.0.2` |
|
||||
| `START_HOST` | начало пула host-частей |
|
||||
| `END_HOST` | конец пула host-частей |
|
||||
| `USE_IPV6` | `0` или `1` |
|
||||
| `IPV6_PREFIX` | prefix, оканчивающийся `::`, если IPv6 включён |
|
||||
| `DEFAULT_CLIENT_MODE` | `full-tunnel` или `split-tunnel` |
|
||||
| `RECENT_HANDSHAKE_SECONDS` | неотрицательное окно active-state |
|
||||
| `BACKUP_RETENTION` | bounded число automatic backups |
|
||||
|
||||
Окружение может изолированно переопределить расположение:
|
||||
`WG_MANAGER_CONFIG`, `WG_MANAGER_BACKUP_DIR`, `WG_MANAGER_LOG_DIR`,
|
||||
`WG_MANAGER_METADATA_DIR`, `WG_MANAGER_ARCHIVE_DIR`, and `WG_MANAGER_LOCK_FILE`.
|
||||
`WG_MANAGER_METADATA_DIR`, `WG_MANAGER_ARCHIVE_DIR`, `WG_MANAGER_LOCK_FILE`.
|
||||
`WG_MANAGER_GUM` имеет приоритет над локальным `bin/gum`, затем `PATH`.
|
||||
`WG_MANAGER_NOW` — только документированный deterministic clock override; перед
|
||||
боевым запуском эти переменные следует убрать. Test-only hooks принимаются лишь
|
||||
в confined `WG_MANAGER_TEST_MODE` под `TEST_ROOT` и не являются production API.
|
||||
|
||||
`WG_MANAGER_GUM` selects an executable `gum` explicitly; otherwise the bundled
|
||||
`bin/gum` and then `PATH` are checked in that order. Configuration is parsed as
|
||||
restricted `KEY=VALUE` data and is never executed as shell code. Blank lines and
|
||||
full-line `#` comments are ignored; spaces around the key, `=`, and value are
|
||||
trimmed. Values are either one unquoted token or a double-quoted value without
|
||||
escapes, and only documented keys are accepted.
|
||||
## Команды и коды
|
||||
|
||||
For deterministic read-only tests, `WG_MANAGER_NOW` may override the current
|
||||
epoch when set to decimal seconds; invalid values are rejected.
|
||||
| Команда | Поведение, stdout и подтверждение |
|
||||
|---|---|
|
||||
| `help`, `version` | безопасная справка/версия, без mutation |
|
||||
| `status` | disk/runtime summary, без secrets |
|
||||
| `list` | tab-separated peer rows с `state`/`origin`, без secrets |
|
||||
| `add NAME [--mode MODE] [--split-networks CIDRS] [--endpoint HOST]` | явный add под lock |
|
||||
| `show NAME` | identity-safe details и safe path, если он доступен |
|
||||
| `config NAME` | печатает только валидированный путь |
|
||||
| `config NAME --show` | единственная форма полного client config |
|
||||
| `qr NAME` | QR через stdin; без `qrencode` печатает путь и возвращает `69` |
|
||||
| `remove NAME [--yes]` | managed peer, archive вместо purge; noninteractive требует `--yes` |
|
||||
| `diagnose [--quiet]` | read-only checks; quiet даёт `id=STATUS` |
|
||||
| `backup` | ручная backup текущего server config под lock |
|
||||
| `backups` | только валидные backups current interface, newest-first |
|
||||
| `restore BACKUP [--yes]` | CLI: без `--yes` нужен обычный интерактивный `y/N`; без terminal команда отклоняется. В TUI требуется ввод полного backup ID; сначала создаётся current-state backup |
|
||||
| `log [LIMIT]` | validated audit records newest-first, `LIMIT` 1..1000 |
|
||||
| `tui` или запуск без аргументов | gum TUI, требует terminal stdin и stdout |
|
||||
|
||||
M2 adds read-only `status` and `list`. They parse the configured WireGuard file
|
||||
and, when available, `wg show <interface> dump`; a stopped or unavailable
|
||||
runtime is reported as unavailable and disk-only peers remain visible. `list`
|
||||
uses stable tab-separated output with English machine fields `state` and
|
||||
`origin` (`managed`, `legacy`, or `unmanaged`) and never prints private or
|
||||
preshared keys.
|
||||
Коды: `0` — успех или безопасная отмена; `64` — usage/flags/arity; `65` —
|
||||
validation или unsafe identity/path; `69` — отсутствующая optional capability;
|
||||
`70` — operation/backend failure; `71` — critical rollback/recovery failure;
|
||||
`77` — недостаточные права. Ошибка audit после mutation сообщается как partial
|
||||
operation и не маскирует исходный status. `FAIL` в diagnose даёт `70`,
|
||||
`WARN`/`SKIP` сами по себе не дают failure.
|
||||
|
||||
The explicit peer comment marker is `# wg-manager-client: NAME`; it is only a
|
||||
display candidate. A peer is considered managed only when its exact public key
|
||||
and configured addresses match a valid metadata record with `MANAGED=1`.
|
||||
Примеры используют только документационные адреса и имена:
|
||||
|
||||
## M3 transaction foundation
|
||||
```bash
|
||||
./wg-manager.sh status
|
||||
./wg-manager.sh list
|
||||
./wg-manager.sh add alice --mode full-tunnel
|
||||
./wg-manager.sh add bob --mode split-tunnel --split-networks '192.0.2.0/24,2001:db8:40::/48' --endpoint vpn.example.invalid
|
||||
./wg-manager.sh show alice
|
||||
./wg-manager.sh config alice
|
||||
./wg-manager.sh config alice --show
|
||||
./wg-manager.sh qr alice
|
||||
./wg-manager.sh remove alice --yes
|
||||
./wg-manager.sh diagnose --quiet
|
||||
./wg-manager.sh log 25
|
||||
./wg-manager.sh backup
|
||||
./wg-manager.sh backups
|
||||
./wg-manager.sh restore 20231114T221500Z-wg0-001 --yes
|
||||
./wg-manager.sh tui
|
||||
./add-user.sh alice vpn.example.invalid
|
||||
```
|
||||
|
||||
The internal `run_transaction OPERATION TARGET BUILDER [METADATA_FILE...]`
|
||||
primitive acquires `WG_MANAGER_LOCK_FILE`, then
|
||||
reloads and validates the live configuration before calling its builder. It
|
||||
creates a restrictive, timestamped backup, validates a same-filesystem staged
|
||||
configuration with `wg-quick strip`, replaces the live file atomically, applies
|
||||
it with `wg syncconf`, and verifies the active peer set. Apply or verification
|
||||
failures restore both disk and runtime; a failed restoration is reported as a
|
||||
critical rollback error. No M3 mutation command is exposed yet.
|
||||
`add-user.sh` — source-safe compatibility wrapper: он только передаёт
|
||||
`add NAME [--endpoint HOST]`, не исполняет аргументы и не содержит второй
|
||||
реализации backend.
|
||||
|
||||
M3 tests use only isolated overrides and deny-by-default command mocks; they do
|
||||
not inspect or modify host WireGuard state.
|
||||
## Транзакции, backups, archive и audit
|
||||
|
||||
## M4 client creation
|
||||
Каждая mutation повторно читает ресурсы после lock, валидирует staged config,
|
||||
создаёт backup, делает atomic replace, выполняет `wg syncconf`, затем сравнивает
|
||||
runtime dump. При ошибке восстанавливаются disk и runtime; если восстановление
|
||||
не доказано, возвращается `71` и сообщение `КРИТИЧНО`. Add публикует client
|
||||
artifacts только после успешной server transaction. Remove архивирует связанные
|
||||
файлы в новый restrictive каталог и не удаляет архивы автоматически. Restore
|
||||
перед применением создаёт backup текущего состояния, не восстанавливает client
|
||||
metadata/key files и принимает только validated backup своего интерфейса.
|
||||
|
||||
`add NAME` creates a client transactionally. The optional forms are
|
||||
`--mode full-tunnel|split-tunnel` and `--endpoint HOST`; unknown or extra
|
||||
arguments are rejected. Names are restricted to ASCII letters, digits, `.`,
|
||||
`_`, and `-` (without a leading `-`). Root is required for production use;
|
||||
tests enable only an isolated `WG_MANAGER_TEST_MODE` under `TEST_ROOT`.
|
||||
Audit line содержит только UTC timestamp, actor UID, safe target, outcome и
|
||||
короткую error category. PrivateKey, PresharedKey, полный config, команды и
|
||||
окружение туда не попадают. `log` не source-ит записи и пропускает
|
||||
malformed/hostile lines. Handshake — текущее значение dump, не история.
|
||||
`firewall=OK` означает только видимое ожидаемое NAT-свидетельство, не полную
|
||||
проверку политики firewall.
|
||||
|
||||
Private/public/PSK files, client configuration, and metadata use restrictive
|
||||
permissions and are removed if the server transaction or rollback fails.
|
||||
Generated key material is supplied to `wg` through stdin or stdout and is not
|
||||
placed in command arguments or diagnostic output.
|
||||
## TUI
|
||||
|
||||
## M5 inspection and export
|
||||
TUI требует пригодный `gum` и оба terminal descriptors. Main screen показывает
|
||||
interface/runtime/managed/active/warning counts; Escape возвращает на предыдущий
|
||||
экран, отмена mutation ничего не меняет. Клиент выбирается по стабильной
|
||||
trusted identity, а не по decorated name. Config/QR требуют отдельного действия;
|
||||
missing `qrencode` — безопасный fallback. Critical rollback немедленно выходит
|
||||
с `71`; обычная ошибка сохраняет диагностический текст и возвращается в меню
|
||||
ограниченное число раз. Backend functions не вызывают gum напрямую.
|
||||
|
||||
`show NAME` displays only non-secret peer details and a config path after
|
||||
identity, ownership, mode, and path validation. `config NAME` prints only that
|
||||
validated path. Full client content requires the explicit noninteractive
|
||||
`config NAME --show` form; it never reads separate `.key` or `.psk` files.
|
||||
## Offline-проверка
|
||||
|
||||
`qr NAME` feeds the validated config to `qrencode -t ansiutf8` through stdin.
|
||||
If the optional `qrencode` dependency is unavailable, it prints the safe config
|
||||
path and returns status `69`; QR failures return the operation status. Routine
|
||||
show/export/path/error output is secret-free.
|
||||
Тесты используют только temporary `TEST_ROOT`, deny-by-default mocks и placeholder
|
||||
keys. Они не читают реальный WireGuard/network/service/firewall/sysctl state:
|
||||
|
||||
The inspection commands do not require `gum` or `qrencode` for `show` and
|
||||
`config`. `qrencode` is the only optional QR dependency; when it is absent,
|
||||
`qr NAME` writes the validated configuration path to stdout and an actionable
|
||||
warning to stderr, then returns status `69`. A nonzero `qrencode` result
|
||||
returns status `70` without printing configuration content in the diagnostic.
|
||||
```bash
|
||||
bash -n wg-manager.sh add-user.sh tests/run.sh tests/helpers.sh
|
||||
bash tests/run.sh
|
||||
bash tests/run.sh m11-e2e
|
||||
bash tests/run.sh secret-scan
|
||||
bash tests/run.sh no-host-writes
|
||||
bash tests/run.sh no-host-command-bypass
|
||||
bash tests/run.sh symlink-safety
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Common command statuses are `64` for usage errors, `65` for invalid or unsafe
|
||||
client identity/path data, `69` for a missing optional dependency, and `70`
|
||||
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.
|
||||
Тестовые mocks не заменяют kernel WireGuard, distribution-specific systemd,
|
||||
firewall policy или архитектурный smoke-test на disposable interface.
|
||||
|
||||
## M6 удаление клиента
|
||||
## Troubleshooting
|
||||
|
||||
`remove NAME [--yes]` удаляет только подтверждённый managed-peer после повторной проверки точного публичного ключа, адресов и блока под lock. В интерактивном терминале без `--yes` показываются имя, адреса и только последние 8 символов публичного ключа; ответ, отличный от `y`, отменяет операцию без изменений. В неинтерактивном режиме `--yes` обязателен.
|
||||
- `65`: проверьте root-owned `0600` config, абсолютные пути, отсутствие symlink
|
||||
и соответствие интерфейса.
|
||||
- `69` для TUI/QR: установите совместимый gum/qrencode самостоятельно или
|
||||
используйте CLI без capability; TUI не скачивает бинарники.
|
||||
- `70`: проверьте stderr, lock и доступность `wg-quick`/`wg`; исходный config
|
||||
должен оставаться восстановленным.
|
||||
- `71 КРИТИЧНО`: остановите mutation, сохраните backup/archive и вручную
|
||||
проверьте disk/runtime перед повтором.
|
||||
- `diagnose` `SKIP`: capability отсутствует или systemd не является active init.
|
||||
- Для gum проверьте архитектуру, `WG_MANAGER_GUM`, локальный `bin/gum` и `PATH`.
|
||||
|
||||
Изменение серверной конфигурации выполняется внутри транзакции: сначала конфигурация заменяется и проверяется в WireGuard, а затем финализатор архивирует связанные `.conf`, `.meta`, `.key`, `.pub` и `.psk` (если существуют) в уникальный каталог `ARCHIVE_DIR` с режимами `0700`/`0600`. Ошибка архивирования после применения приводит к откату и оставляет исходные файлы доступными для восстановления; архивы не удаляются автоматически и служат восстановительным материалом. Журнал содержит operation, target, UTC timestamp, outcome и короткий суффикс публичного ключа; ключи и содержимое конфигураций в него не записываются.
|
||||
## M11 evidence matrix
|
||||
|
||||
`remove` возвращает `64` для ошибки аргументов, `65` для неподтверждённой или небезопасной identity/path, `69` для отсутствующей обязательной зависимости и `70` для ошибки операции или архивации. Ошибка аудита после успешной мутации явно сообщается и возвращает операционный статус; отмена остаётся успешной без изменения состояния.
|
||||
`diagnose [--quiet]` выполняет только read-only проверки конфигурации и прав, наличия интерфейса, `wg` dump, forwarding, адресов/маршрутов IPv4 и IPv6, UDP-порта, firewall, systemd, runtime-vs-disk peer set и журнала. Отдельные записи `config-parse`, `duplicate-keys`, `allowedips-overlap` и `key-permissions` не скрывают независимые причины сбоя. Каждая запись имеет один из статусов `OK`, `WARN`, `FAIL` или `SKIP`; `--quiet` выводит только стабильные строки `id=STATUS`. Обязательны `wg`, `wg-quick` и `ip`; `systemctl`, `journalctl`, `nft`, `iptables` и `ss` являются optional и при отсутствии дают собственный `SKIP`. На хосте без активного systemd проверки systemd и журнала пропускаются. `firewall=OK` означает только видимое ожидаемое NAT-свидетельство, а не полную проверку политики firewall. Ненулевой код (`70`) возвращается при любом `FAIL`, `64` — при неверных аргументах. Команда не изменяет конфигурацию, runtime, маршруты, firewall или sysctl; handshake и журнал имеют ограниченную историческую видимость. Тесты используют только изолированные fixtures/mocks и не доказывают состояние реального сервера.
|
||||
| Требование | Evidence |
|
||||
|---|---|
|
||||
| status / list | `status`, `list`, `m11-e2e` — машинный вывод и смешанные origins |
|
||||
| add и allocation | `add-client`, `tui-add`, `m11-e2e` — полная/выборочная сеть и rollback |
|
||||
| show / config export | `show-client`, `config-export`, `m11-e2e` |
|
||||
| QR и fallback | `qr`, `tui-client-actions`, `secret-scan` |
|
||||
| remove / archive | `remove-client`, `archive-client`, `tui-remove`, `m11-e2e` |
|
||||
| diagnose | `diagnostics`, `diagnostics-exit-codes`, `m11-e2e` |
|
||||
| backup / backups | `backup-list`, `runtime-file-modes`, `tui-backup` |
|
||||
| restore | `restore`, `restore-rollback`, `tui-restore`, `m11-e2e` |
|
||||
| audit / log | `audit`, `audit-no-secrets`, `secret-scan` |
|
||||
| public-key identity | `parser`, `show-client`, `remove-client`, `tui-client-actions` |
|
||||
| transaction, apply и rollback | `transaction`, `rollback`, `add-rollback`, `m11-e2e` |
|
||||
| paths, symlinks и modes | `path-safety`, `symlink-safety`, `backup-path-safety`, `runtime-file-modes` |
|
||||
| secret redaction | `no-secret-logs`, `secret-scan`, `tui-no-secret-args` |
|
||||
| host write/command isolation | `no-host-writes`, `no-host-command-bypass` |
|
||||
| exit/status semantics | `config`, `diagnostics-exit-codes`, `rollback`, CLI-focused groups |
|
||||
| docs и deployment | этот README и disposable checklist; live-server deployment остаётся ручным шагом |
|
||||
|
||||
## M8 резервные копии, восстановление и аудит
|
||||
Это offline evidence, а не обещание, что live server уже настроен или что
|
||||
deployment checklist выполнен.
|
||||
|
||||
`backup` создаёт ручную резервную копию текущей серверной конфигурации под lock; `backups` показывает только проверенные прямые дочерние каталоги, в порядке от новых к старым. Повреждённые или неполные записи не исполняются и не читаются как shell-код. `restore BACKUP [--yes]` восстанавливает только серверную конфигурацию: перед заменой текущая версия также резервируется, а ошибки применения проверяют и откатывают disk/runtime. Восстановление другой WireGuard-интерфейсной конфигурации отклоняется. Без `--yes` требуется подтверждение в терминале; в неинтерактивном режиме команда возвращает ошибку использования. Клиентские metadata/key-файлы восстановлением не изменяются.
|
||||
## Disposable deployment checklist (не выполнен)
|
||||
|
||||
`log [LIMIT]` выводит только корректные структурированные audit-события в newest-first порядке; `LIMIT` находится в диапазоне 1..1000. При отсутствии журнала выводится безопасное сообщение. Записи содержат UTC timestamp, actor UID, operation, target, outcome и короткую категорию ошибки; секреты, команды и полные конфигурации не записываются. Ошибки аудита после успешной мутации сообщаются как частичный операционный результат. Основные коды: `64` — аргументы, `65` — небезопасная/невалидная backup identity, `70` — ошибка операции; критический неудачный rollback сохраняет отдельный rollback status.
|
||||
1. Создать отдельный non-production interface и disposable host.
|
||||
2. Скопировать каталог проекта, не устанавливая его в `PATH`.
|
||||
3. Проверить Bash, WireGuard tools, coreutils/util-linux и архитектуру gum.
|
||||
4. Скопировать example config и заменить только placeholder values.
|
||||
5. Проверить owner root, config `0600` и каталоги `0700`.
|
||||
6. Выполнить `help`, `version`, `status` и `list`.
|
||||
7. Выполнить `diagnose` и сохранить безопасный отчёт.
|
||||
8. Добавить disposable client и проверить modes/metadata/audit.
|
||||
9. Проверить `show`, safe `config` path и QR fallback.
|
||||
10. Удалить client с явным подтверждением и проверить archive.
|
||||
11. Создать backup и проверить newest-first listing.
|
||||
12. Восстановить backup с typed confirmation на disposable config.
|
||||
13. Проверить lock contention и rollback на тестовом runtime.
|
||||
14. Сверить SSH/noninteractive, firewall visibility и systemd limitations.
|
||||
15. Только после human review решить, допустим ли production rollout.
|
||||
|
||||
Этот список намеренно не выполняется агентом и не является разрешением менять
|
||||
реальный host state без отдельной проверки оператором.
|
||||
|
||||
+23
-192
@@ -1,200 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
WG_INTERFACE="wg0"
|
||||
WG_CONF="/etc/wireguard/${WG_INTERFACE}.conf"
|
||||
WG_DIR="/etc/wireguard"
|
||||
CLIENT_DIR="${WG_DIR}/clients"
|
||||
SERVER_PORT="51820"
|
||||
DEFAULT_ENDPOINT="37.26.136.232"
|
||||
SERVER_VPN_IP="10.9.0.1"
|
||||
VPN_SUBNET_PREFIX="10.9.0"
|
||||
START_HOST="2"
|
||||
END_HOST="254"
|
||||
USE_IPV6="0"
|
||||
IPV6_PREFIX="fd05:13:53::"
|
||||
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
die() {
|
||||
echo "Ошибка: $*" >&2
|
||||
exit 1
|
||||
# Compatibility entry point. Client mutation has one implementation in
|
||||
# wg-manager.sh; this wrapper deliberately contains no WireGuard operations.
|
||||
ADD_USER_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly ADD_USER_SCRIPT_DIR
|
||||
|
||||
add_user_usage() {
|
||||
printf 'Использование: %s NAME [ENDPOINT]\n' "${ADD_USER_SCRIPT_DIR}/add-user.sh" >&2
|
||||
printf 'Устаревший wrapper: используйте %s add NAME [--endpoint HOST]\n' \
|
||||
"${ADD_USER_SCRIPT_DIR}/wg-manager.sh" >&2
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Не найдена команда: $1"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Использование:
|
||||
sudo $0 CLIENT_NAME [ENDPOINT]
|
||||
|
||||
Примеры:
|
||||
sudo $0 android-phone
|
||||
sudo $0 work-laptop vpn.example.com
|
||||
sudo $0 user5 37.26.136.232
|
||||
|
||||
Что делает:
|
||||
- создаёт ключи клиента
|
||||
- подбирает следующий свободный IP
|
||||
- добавляет peer в ${WG_CONF}
|
||||
- создаёт клиентский конфиг в ${CLIENT_DIR}
|
||||
- применяет конфиг WireGuard
|
||||
EOF
|
||||
}
|
||||
|
||||
[[ $# -ge 1 ]] || { usage; exit 1; }
|
||||
|
||||
CLIENT_NAME="$1"
|
||||
ENDPOINT="${2:-$DEFAULT_ENDPOINT}"
|
||||
|
||||
[[ "$EUID" -eq 0 ]] || die "Скрипт нужно запускать через sudo/root"
|
||||
[[ -f "$WG_CONF" ]] || die "Не найден конфиг ${WG_CONF}"
|
||||
|
||||
need_cmd wg
|
||||
need_cmd awk
|
||||
need_cmd grep
|
||||
need_cmd sed
|
||||
need_cmd sort
|
||||
need_cmd tee
|
||||
need_cmd mkdir
|
||||
need_cmd chmod
|
||||
|
||||
mkdir -p "$CLIENT_DIR"
|
||||
|
||||
if [[ ! "$CLIENT_NAME" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||
die "Имя клиента может содержать только буквы, цифры, точку, подчёркивание и дефис"
|
||||
fi
|
||||
|
||||
CLIENT_CONF="${CLIENT_DIR}/${WG_INTERFACE}-client-${CLIENT_NAME}.conf"
|
||||
CLIENT_PRIVKEY_FILE="${CLIENT_DIR}/${CLIENT_NAME}.key"
|
||||
CLIENT_PUBKEY_FILE="${CLIENT_DIR}/${CLIENT_NAME}.pub"
|
||||
CLIENT_PSK_FILE="${CLIENT_DIR}/${CLIENT_NAME}.psk"
|
||||
|
||||
if grep -qE "^### Client ${CLIENT_NAME}$" "$WG_CONF"; then
|
||||
die "Клиент с именем '${CLIENT_NAME}' уже есть в ${WG_CONF}"
|
||||
fi
|
||||
|
||||
if [[ -e "$CLIENT_CONF" || -e "$CLIENT_PRIVKEY_FILE" || -e "$CLIENT_PUBKEY_FILE" || -e "$CLIENT_PSK_FILE" ]]; then
|
||||
die "Файлы клиента '${CLIENT_NAME}' уже существуют в ${CLIENT_DIR}"
|
||||
fi
|
||||
|
||||
|
||||
find_next_ipv4() {
|
||||
local used host
|
||||
used="$(
|
||||
grep -oE "${VPN_SUBNET_PREFIX//./\\.}\.[0-9]+/32" "$WG_CONF" \
|
||||
| sed 's#/32##' \
|
||||
| awk -F. '{print $4}' \
|
||||
| sort -n \
|
||||
| uniq
|
||||
)"
|
||||
|
||||
for host in $(seq "$START_HOST" "$END_HOST"); do
|
||||
if ! grep -qx "$host" <<<"$used"; then
|
||||
echo "${VPN_SUBNET_PREFIX}.${host}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
CLIENT_IPV4="$(find_next_ipv4)" || die "Не удалось найти свободный IPv4 адрес"
|
||||
CLIENT_HOST_OCTET="${CLIENT_IPV4##*.}"
|
||||
|
||||
if [[ "$USE_IPV6" == "1" ]]; then
|
||||
CLIENT_IPV6="${IPV6_PREFIX}${CLIENT_HOST_OCTET}"
|
||||
fi
|
||||
|
||||
echo "Создаю клиента: ${CLIENT_NAME}"
|
||||
echo "IPv4 клиента: ${CLIENT_IPV4}"
|
||||
if [[ "$USE_IPV6" == "1" ]]; then
|
||||
echo "IPv6 клиента: ${CLIENT_IPV6}"
|
||||
fi
|
||||
|
||||
wg genkey | tee "$CLIENT_PRIVKEY_FILE" | wg pubkey > "$CLIENT_PUBKEY_FILE"
|
||||
wg genpsk > "$CLIENT_PSK_FILE"
|
||||
|
||||
chmod 600 "$CLIENT_PRIVKEY_FILE" "$CLIENT_PUBKEY_FILE" "$CLIENT_PSK_FILE"
|
||||
|
||||
CLIENT_PRIVATE_KEY="$(cat "$CLIENT_PRIVKEY_FILE")"
|
||||
CLIENT_PUBLIC_KEY="$(cat "$CLIENT_PUBKEY_FILE")"
|
||||
CLIENT_PRESHARED_KEY="$(cat "$CLIENT_PSK_FILE")"
|
||||
|
||||
if command -v wg >/dev/null 2>&1 && wg show "$WG_INTERFACE" >/dev/null 2>&1; then
|
||||
SERVER_PUBLIC_KEY="$(wg show "$WG_INTERFACE" public-key)"
|
||||
else
|
||||
SERVER_PUBLIC_KEY="$(wg pubkey < <(grep '^PrivateKey *= *' "$WG_CONF" | head -n1 | awk -F'=' '{gsub(/ /,"",$2); print $2}'))"
|
||||
fi
|
||||
|
||||
TMP_PEER_FILE="$(mktemp)"
|
||||
trap 'rm -f "$TMP_PEER_FILE"' EXIT
|
||||
|
||||
{
|
||||
echo
|
||||
echo "### Client ${CLIENT_NAME}"
|
||||
echo "[Peer]"
|
||||
echo "PublicKey = ${CLIENT_PUBLIC_KEY}"
|
||||
echo "PresharedKey = ${CLIENT_PRESHARED_KEY}"
|
||||
if [[ "$USE_IPV6" == "1" ]]; then
|
||||
echo "AllowedIPs = ${CLIENT_IPV4}/32,${CLIENT_IPV6}/128"
|
||||
else
|
||||
echo "AllowedIPs = ${CLIENT_IPV4}/32"
|
||||
add_user_main() {
|
||||
local -a args
|
||||
if (( $# < 1 || $# > 2 )); then
|
||||
add_user_usage
|
||||
return 64
|
||||
fi
|
||||
} > "$TMP_PEER_FILE"
|
||||
args=(add "$1")
|
||||
if (( $# == 2 )); then
|
||||
args+=(--endpoint "$2")
|
||||
fi
|
||||
exec "${ADD_USER_SCRIPT_DIR}/wg-manager.sh" "${args[@]}"
|
||||
}
|
||||
|
||||
cat "$TMP_PEER_FILE" >> "$WG_CONF"
|
||||
|
||||
if [[ "$USE_IPV6" == "1" ]]; then
|
||||
CLIENT_ADDRESS="${CLIENT_IPV4}/32,${CLIENT_IPV6}/128"
|
||||
CLIENT_ALLOWED_IPS="0.0.0.0/0,::/0"
|
||||
else
|
||||
CLIENT_ADDRESS="${CLIENT_IPV4}/32"
|
||||
CLIENT_ALLOWED_IPS="0.0.0.0/0"
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
add_user_main "$@"
|
||||
fi
|
||||
|
||||
cat > "$CLIENT_CONF" <<EOF
|
||||
[Interface]
|
||||
PrivateKey = ${CLIENT_PRIVATE_KEY}
|
||||
Address = ${CLIENT_ADDRESS}
|
||||
DNS = ${SERVER_VPN_IP},192.168.0.200
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${SERVER_PUBLIC_KEY}
|
||||
PresharedKey = ${CLIENT_PRESHARED_KEY}
|
||||
Endpoint = ${ENDPOINT}:${SERVER_PORT}
|
||||
AllowedIPs = ${CLIENT_ALLOWED_IPS}
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
|
||||
chmod 600 "$CLIENT_CONF"
|
||||
|
||||
echo "Применяю конфиг WireGuard..."
|
||||
wg syncconf "$WG_INTERFACE" <(wg-quick strip "$WG_INTERFACE")
|
||||
|
||||
echo
|
||||
echo "Готово."
|
||||
echo "Клиент: ${CLIENT_NAME}"
|
||||
echo "IP: ${CLIENT_IPV4}"
|
||||
echo "Конфиг: ${CLIENT_CONF}"
|
||||
echo
|
||||
|
||||
if command -v qrencode >/dev/null 2>&1; then
|
||||
echo "QR-код для клиента:"
|
||||
qrencode -t ansiutf8 < "$CLIENT_CONF"
|
||||
echo
|
||||
else
|
||||
echo "Пакет qrencode не установлен, QR-код не напечатан."
|
||||
echo "Установить можно так:"
|
||||
echo " sudo apt update && sudo apt install -y qrencode"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "Проверка:"
|
||||
echo " sudo wg show ${WG_INTERFACE}"
|
||||
echo
|
||||
echo "Передать клиенту файл:"
|
||||
echo " ${CLIENT_CONF}"
|
||||
|
||||
Vendored
+1
-1
@@ -1,2 +1,2 @@
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= 51820 0
|
||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 1700000100 100 200 25
|
||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 1700000100 100 200 off
|
||||
|
||||
+44
-9
@@ -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) [[ -n $answer ]] || exit 1; 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
|
||||
|
||||
+1133
-4
File diff suppressed because it is too large
Load Diff
+412
-25
@@ -2,6 +2,16 @@
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
wg_manager_bash_supported() {
|
||||
local required_major=${1:-4} required_minor=${2:-3} actual_major=${3:-${BASH_VERSINFO[0]}} actual_minor=${4:-${BASH_VERSINFO[1]}}
|
||||
(( actual_major > required_major || (actual_major == required_major && actual_minor >= required_minor) ))
|
||||
}
|
||||
|
||||
if ! wg_manager_bash_supported 4 3; then
|
||||
printf 'Ошибка: требуется Bash версии 4.3 или новее\n' >&2
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then exit 69; else return 69; fi
|
||||
fi
|
||||
|
||||
resolve_script_dir() {
|
||||
local source_path=${BASH_SOURCE[0]} source_dir
|
||||
if [[ $source_path == /* ]]; then
|
||||
@@ -54,9 +64,11 @@ print_usage() {
|
||||
Использование:
|
||||
wg-manager.sh help
|
||||
wg-manager.sh version
|
||||
wg-manager.sh {status|list|add NAME [--mode MODE] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose [--quiet]|backup|backups|restore BACKUP [--yes]|log [LIMIT]|tui}
|
||||
wg-manager.sh {status|list|add NAME [--mode MODE] [--split-networks CIDRS] [--endpoint HOST]|show NAME|config NAME [--show]|qr NAME|remove NAME [--yes]|diagnose [--quiet]|backup|backups|restore BACKUP [--yes]|log [LIMIT]|tui}
|
||||
|
||||
Команды управления WireGuard будут добавлены в следующих версиях.
|
||||
Основные команды: status, list, add, show, config, qr, remove, diagnose,
|
||||
backup, backups, restore, log и tui. Ошибки usage/validation/dependency/
|
||||
operation/rollback возвращают соответственно 64/65/69/70/71.
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -492,6 +504,12 @@ parse_metadata() {
|
||||
for file in "$METADATA_DIR"/*; do [[ -e "$file" ]] || continue; parse_metadata_file "$file" || return $?; done
|
||||
}
|
||||
|
||||
validate_dump_keepalive() {
|
||||
local value=$1
|
||||
[[ $value == off ]] && return 0
|
||||
is_uint "$value" && (( 10#$value <= 65535 ))
|
||||
}
|
||||
|
||||
parse_runtime_dump() {
|
||||
local line fields_count line_number=0 key endpoint allowed handshake rx tx keepalive
|
||||
local output=$1
|
||||
@@ -513,7 +531,7 @@ parse_runtime_dump() {
|
||||
[[ $endpoint == '-' || -z $endpoint ]] || validate_peer_endpoint_value "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный Endpoint в wg dump'; return $?; }
|
||||
[[ -z ${RUNTIME_HANDSHAKE[$key]+x} ]] || { die "$EXIT_VALIDATION" 'Дублирующийся PublicKey в wg dump'; return $?; }
|
||||
if [[ $allowed != '-' ]]; then normalize_allowed_ips "$allowed" >/dev/null || { die "$EXIT_VALIDATION" 'Некорректные AllowedIPs в wg dump'; return $?; }; fi
|
||||
is_uint "$handshake" && is_uint "$rx" && is_uint "$tx" && is_uint "$keepalive" || { die "$EXIT_VALIDATION" 'Некорректные числа в wg dump'; return $?; }
|
||||
is_uint "$handshake" && is_uint "$rx" && is_uint "$tx" && validate_dump_keepalive "$keepalive" || { die "$EXIT_VALIDATION" 'Некорректные числа в wg dump'; return $?; }
|
||||
RUNTIME_ENDPOINT["$key"]=${endpoint:--}; if [[ $allowed == '-' ]]; then RUNTIME_ALLOWED["$key"]='-'; else RUNTIME_ALLOWED["$key"]=$(normalize_allowed_ips "$allowed"); fi; RUNTIME_HANDSHAKE["$key"]=$handshake; RUNTIME_RX["$key"]=$rx; RUNTIME_TX["$key"]=$tx
|
||||
done <<<"$output"
|
||||
}
|
||||
@@ -836,7 +854,7 @@ transaction_subshell_exit() {
|
||||
local status=$?
|
||||
transaction_cleanup
|
||||
if [[ -n ${TRANSACTION_RESULT_FILE:-} ]]; then
|
||||
printf 'STATUS=%s\nBACKUP=%s\nOUTCOME=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" "${TRANSACTION_OUTCOME:-failed-before-apply}" >"$TRANSACTION_RESULT_FILE"
|
||||
printf 'STATUS=%s\nBACKUP=%s\nFINALIZER=%s\nOUTCOME=%s\n' "$status" "${TRANSACTION_BACKUP_PATH:-}" "${REMOVE_ARCHIVE_PATH:-}" "${TRANSACTION_OUTCOME:-failed-before-apply}" >"$TRANSACTION_RESULT_FILE"
|
||||
if ! chmod 600 -- "$TRANSACTION_RESULT_FILE"; then
|
||||
printf 'Предупреждение: не удалось защитить transaction result\n' >&2
|
||||
fi
|
||||
@@ -986,7 +1004,7 @@ run_transaction() {
|
||||
die "$EXIT_USAGE" 'Ожидаются operation, target и builder транзакции'
|
||||
return $?
|
||||
fi
|
||||
local operation=$1 target=$2 builder=$3 result_file result_status result_backup result_outcome had_errexit=0
|
||||
local operation=$1 target=$2 builder=$3 result_file result_status result_backup result_finalizer result_outcome had_errexit=0
|
||||
shift 3
|
||||
[[ -n ${WG_MANAGER_CONFIG:-} ]] || { die "$EXIT_VALIDATION" 'Не задан manager config'; return $?; }
|
||||
set_runtime_paths || return $?
|
||||
@@ -1005,11 +1023,12 @@ run_transaction() {
|
||||
(( had_errexit == 1 )) && set -e || set +e
|
||||
if [[ -r $result_file ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; OUTCOME) result_outcome=$value ;; esac
|
||||
case $key in STATUS) result_status=$value ;; BACKUP) result_backup=$value ;; FINALIZER) result_finalizer=$value ;; OUTCOME) result_outcome=$value ;; esac
|
||||
done <"$result_file"
|
||||
fi
|
||||
rm -f -- "$result_file"
|
||||
TRANSACTION_BACKUP_PATH=${result_backup:-}
|
||||
[[ -n ${result_finalizer:-} ]] && REMOVE_ARCHIVE_RESULT=$result_finalizer
|
||||
TRANSACTION_OUTCOME=${result_outcome:-failed-before-apply}
|
||||
if [[ $result_outcome == success ]]; then
|
||||
if ! write_audit_event apply "$target" success success >/dev/null 2>&1; then warn 'Не удалось записать аудит применения'; fi
|
||||
@@ -1097,7 +1116,7 @@ allocate_client_ipv6() {
|
||||
client_allowed_ips() {
|
||||
case $1 in
|
||||
full-tunnel) printf '0.0.0.0/0,::/0' ;;
|
||||
split-tunnel) printf '%s.0/24' "$VPN_SUBNET_PREFIX" ;;
|
||||
split-tunnel) printf '%s' "${ADD_SPLIT_NETWORKS:-${VPN_SUBNET_PREFIX}.0/24}" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -1294,12 +1313,12 @@ clear_add_transaction_state() {
|
||||
for path in "$ADD_META_STAGE" "$ADD_CONFIG_STAGE" "$ADD_KEY_STAGE" "$ADD_PUB_STAGE" "$ADD_PSK_STAGE"; do
|
||||
[[ -n ${path:-} ]] && rm -f -- "$path"
|
||||
done
|
||||
unset ADD_NAME ADD_MODE ADD_ENDPOINT ADD_PRIVATE_KEY ADD_PUBLIC_KEY ADD_PSK ADD_META_STAGE ADD_CONFIG_STAGE ADD_KEY_STAGE ADD_PUB_STAGE ADD_PSK_STAGE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX
|
||||
unset ADD_NAME ADD_MODE ADD_ENDPOINT ADD_SPLIT_NETWORKS ADD_PRIVATE_KEY ADD_PUBLIC_KEY ADD_PSK ADD_META_STAGE ADD_CONFIG_STAGE ADD_KEY_STAGE ADD_PUB_STAGE ADD_PSK_STAGE ADD_PUBLISH_READY_FIFO ADD_PUBLISH_RELEASE_FIFO ADD_PUBLISH_PID_FILE ADD_PUBLISH_READY_INDEX
|
||||
TRANSACTION_FINALIZER=''
|
||||
}
|
||||
|
||||
add_client_impl() {
|
||||
local name mode endpoint arg mode_seen=0 endpoint_seen=0
|
||||
local name mode endpoint arg split_networks='' mode_seen=0 endpoint_seen=0 split_seen=0
|
||||
(($# >= 1)) || { die "$EXIT_USAGE" 'Ожидается имя клиента'; return $?; }
|
||||
name=$1; shift
|
||||
load_config || return $?
|
||||
@@ -1314,11 +1333,22 @@ add_client_impl() {
|
||||
(( endpoint_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --endpoint указан повторно'; return $?; }
|
||||
(($# >= 2)) || { die "$EXIT_USAGE" 'Для --endpoint требуется значение'; return $?; }
|
||||
endpoint=$2; endpoint_seen=1; shift 2 ;;
|
||||
--split-networks)
|
||||
(( split_seen == 0 )) || { die "$EXIT_USAGE" 'Флаг --split-networks указан повторно'; return $?; }
|
||||
(($# >= 2)) || { die "$EXIT_USAGE" 'Для --split-networks требуется значение'; return $?; }
|
||||
split_networks=$2; split_seen=1; shift 2 ;;
|
||||
*) die "$EXIT_USAGE" 'Неизвестный или лишний аргумент add'; return $? ;;
|
||||
esac
|
||||
done
|
||||
validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
|
||||
[[ $mode == full-tunnel || $mode == split-tunnel ]] || { die "$EXIT_VALIDATION" 'Некорректный режим клиента'; return $?; }
|
||||
if [[ $mode == split-tunnel ]]; then
|
||||
[[ -n $split_networks ]] || split_networks="${VPN_SUBNET_PREFIX}.0/24"
|
||||
split_networks=$(normalize_allowed_ips "$split_networks") || { die "$EXIT_VALIDATION" 'Некорректные сети split-tunnel'; return $?; }
|
||||
elif [[ -n $split_networks ]]; then
|
||||
die "$EXIT_VALIDATION" '--split-networks доступен только для split-tunnel'
|
||||
return $?
|
||||
fi
|
||||
validate_endpoint "$endpoint" || { die "$EXIT_VALIDATION" 'Некорректный endpoint клиента'; return $?; }
|
||||
require_add_privilege || return $?
|
||||
ADD_META_STAGE='' ADD_CONFIG_STAGE='' ADD_KEY_STAGE='' ADD_PUB_STAGE='' ADD_PSK_STAGE=''
|
||||
@@ -1367,7 +1397,7 @@ add_client_impl() {
|
||||
clear_add_transaction_state
|
||||
return "$EXIT_OPERATION"
|
||||
fi
|
||||
ADD_NAME=$name ADD_MODE=$mode ADD_ENDPOINT=$endpoint
|
||||
ADD_NAME=$name ADD_MODE=$mode ADD_ENDPOINT=$endpoint ADD_SPLIT_NETWORKS=$split_networks
|
||||
TRANSACTION_FINALIZER=publish_add_artifacts
|
||||
local status
|
||||
if run_transaction add "$name" add_client_builder; then
|
||||
@@ -1376,7 +1406,7 @@ add_client_impl() {
|
||||
status=$?
|
||||
fi
|
||||
if (( status != 0 )); then
|
||||
if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" failed transaction_failed >/dev/null 2>&1; then warn 'Не удалось записать аудит добавления'; fi
|
||||
if ! AUDIT_CLIENT=$name AUDIT_KEY_SUFFIX='' write_audit_event add "$name" failed transaction-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит добавления'; fi
|
||||
clear_add_transaction_state
|
||||
return "$status"
|
||||
fi
|
||||
@@ -1433,6 +1463,304 @@ 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_input_placeholder() { gum_command input --placeholder "$1"; }
|
||||
gum_input_prompt() { gum_command input --prompt "$1"; }
|
||||
gum_confirm() { gum_command confirm "$@"; }
|
||||
gum_table() { gum_command table "$@"; }
|
||||
gum_pager() { gum_command pager "$@"; }
|
||||
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_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() {
|
||||
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
|
||||
Состояние) if tui_run_action tui_page_command print_status; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
|
||||
Клиенты) if tui_run_action tui_select_client; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
|
||||
Диагностика) if tui_run_action tui_page_command diagnose; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
|
||||
Журнал) if tui_run_action tui_page_command list_audit; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
|
||||
'Резервные копии') if tui_run_action tui_backup_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
|
||||
Добавить) if tui_run_action tui_add_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
|
||||
Удалить) if tui_run_action tui_remove_flow; then :; else status=$?; ((status == EXIT_ROLLBACK)) && { unset TUI_GUM_PATH; return "$status"; }; ((failures += 1)); fi ;;
|
||||
Выход|'') unset TUI_GUM_PATH; return 0 ;;
|
||||
*) printf 'Неизвестный пункт TUI\n' >&2; ((failures += 1)) ;;
|
||||
esac
|
||||
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() {
|
||||
@@ -1668,7 +1996,7 @@ restore_backup() {
|
||||
if (( status == 0 )); then
|
||||
write_audit_event restore "$name" success success >/dev/null 2>&1 || { cleanup_restore_snapshot; die "$EXIT_OPERATION" 'Восстановление выполнено, но аудит не записан'; return "$EXIT_OPERATION"; }
|
||||
else
|
||||
if ! write_audit_event restore "$name" failed transaction_failed >/dev/null 2>&1; then warn 'Не удалось записать аудит восстановления'; fi
|
||||
if ! write_audit_event restore "$name" failed transaction-failed >/dev/null 2>&1; then warn 'Не удалось записать аудит восстановления'; fi
|
||||
fi
|
||||
cleanup_restore_snapshot
|
||||
return "$status"
|
||||
@@ -1905,13 +2233,14 @@ print_client_details() {
|
||||
if [[ ${CLIENT_ORIGINS[$index]} == managed ]]; then printf 'Режим: %s\nСоздан: %s\nПуть конфигурации: %s\n' "${META_MODE[$key]}" "${META_CREATED[$key]}" "$config_path"; fi
|
||||
}
|
||||
|
||||
export_client_config() {
|
||||
local name=$1 show=${2:-0} index key path status=0
|
||||
export_client_config_core() {
|
||||
local name=$1 show=${2:-0} index key path status=0 expected_key=${3:-}
|
||||
[[ $show == 0 || $show == 1 ]] || { die "$EXIT_USAGE" 'Недопустимый режим экспорта конфигурации'; return $?; }
|
||||
load_client_model || return $?
|
||||
index=$(resolve_named_client "$name") || return $?
|
||||
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
|
||||
key=${CLIENT_KEYS[$index]}
|
||||
[[ -z $expected_key || $expected_key == "$key" ]] || { die "$EXIT_VALIDATION" 'Идентичность клиента изменилась'; return $?; }
|
||||
path=$(validate_client_config_path "$key" "$name" "$index") || return $?
|
||||
if (( show == 0 )); then printf '%s\n' "$path"; return 0; fi
|
||||
if open_validated_client_config "$path"; then :; else
|
||||
@@ -1931,6 +2260,16 @@ export_client_config() {
|
||||
if (( status != 0 )); then die "$EXIT_OPERATION" 'Не удалось прочитать конфигурацию клиента'; return $?; fi
|
||||
}
|
||||
|
||||
export_client_config() {
|
||||
(($# >= 1 && $# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы config'; return $?; }
|
||||
export_client_config_core "$1" "${2:-0}"
|
||||
}
|
||||
|
||||
export_client_config_exact() {
|
||||
local name=$1 expected_key=$2 show=${3:-0}
|
||||
export_client_config_core "$name" "$show" "$expected_key"
|
||||
}
|
||||
|
||||
show_client() {
|
||||
local name=$1 index
|
||||
load_client_model || return $?
|
||||
@@ -1938,12 +2277,13 @@ show_client() {
|
||||
print_client_details "$name" "$index"
|
||||
}
|
||||
|
||||
qr_client() {
|
||||
local name=$1 index key path qr_path status
|
||||
qr_client_core() {
|
||||
local name=$1 expected_key=${2:-} index key path qr_path status
|
||||
load_client_model || return $?
|
||||
index=$(resolve_named_client "$name") || return $?
|
||||
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Для этого клиента нет безопасной managed-конфигурации'; return $?; }
|
||||
key=${CLIENT_KEYS[$index]}
|
||||
[[ -z $expected_key || $expected_key == "$key" ]] || { die "$EXIT_VALIDATION" 'Идентичность клиента изменилась'; return $?; }
|
||||
path=$(validate_client_config_path "$key" "$name" "$index") || return $?
|
||||
if qr_path=$(command -v qrencode 2>/dev/null); then :; else qr_path=''; fi
|
||||
if [[ -z $qr_path || ! -f $qr_path || -L $qr_path || ! -x $qr_path ]]; then
|
||||
@@ -1968,8 +2308,27 @@ qr_client() {
|
||||
(( status == 0 )) || { die "$EXIT_OPERATION" 'Не удалось сформировать QR-код'; return "$EXIT_OPERATION"; }
|
||||
}
|
||||
|
||||
qr_client() {
|
||||
(($# == 1)) || { die "$EXIT_USAGE" 'Лишние аргументы qr'; return $?; }
|
||||
qr_client_core "$1"
|
||||
}
|
||||
|
||||
qr_client_exact() {
|
||||
local name=$1 expected_key=$2
|
||||
qr_client_core "$name" "$expected_key"
|
||||
}
|
||||
|
||||
short_public_key() { local key=$1; printf '%s' "${key: -8}"; }
|
||||
|
||||
tui_wrap_value() {
|
||||
local label=$1 value=$2 width=72
|
||||
printf '%s\n' "$label"
|
||||
while [[ -n $value ]]; do
|
||||
printf '%s\n' "${value:0:width}"
|
||||
value=${value:width}
|
||||
done
|
||||
}
|
||||
|
||||
remove_peer_builder() {
|
||||
local stage=$1 target_key=${REMOVE_KEY:?} target_name=${REMOVE_NAME:?}
|
||||
local -a lines=() kept=() block=()
|
||||
@@ -2212,17 +2571,20 @@ confirm_remove() {
|
||||
|
||||
remove_is_interactive() { [[ -t 0 && -t 1 ]]; }
|
||||
|
||||
remove_client_impl() {
|
||||
local name=$1 flag=${2:-} index key path status answer
|
||||
(($# <= 2)) || { die "$EXIT_USAGE" 'Лишние аргументы remove'; return $?; }
|
||||
remove_client_core() {
|
||||
local name=$1 expected_key=$2 flag=$3 index key path status answer
|
||||
REMOVE_ARCHIVE_RESULT=''
|
||||
validate_client_name "$name" || { die "$EXIT_VALIDATION" 'Некорректное имя клиента'; return $?; }
|
||||
[[ -z $flag || $flag == --yes ]] || { die "$EXIT_USAGE" 'Разрешён только флаг remove --yes'; return $?; }
|
||||
load_client_model || return $?
|
||||
path_components_safe "$ARCHIVE_DIR" || { die "$EXIT_VALIDATION" 'Каталог архива содержит символическую ссылку'; return $?; }
|
||||
require_remove_privilege || return $?
|
||||
index=$(resolve_named_client "$name") || return $?
|
||||
[[ ${CLIENT_ORIGINS[$index]} == managed ]] || { die "$EXIT_VALIDATION" 'Удалять можно только managed-клиента'; return $?; }
|
||||
key=${CLIENT_KEYS[$index]}; path=$(validate_client_config_path "$key" "$name" "$index") || return $?
|
||||
if [[ -n $expected_key && $expected_key != "$key" ]]; then
|
||||
die "$EXIT_VALIDATION" 'Идентичность клиента изменилась до удаления'
|
||||
return $?
|
||||
fi
|
||||
REMOVE_NAME=$name REMOVE_KEY=$key REMOVE_CONFIG_PATH=$path REMOVE_META_FILE=${META_FILE[$key]}
|
||||
REMOVE_ALLOWED=${DISK_ALLOWED[$key]}
|
||||
if [[ -z $flag ]] && ! remove_is_interactive; then
|
||||
@@ -2241,15 +2603,30 @@ remove_client_impl() {
|
||||
TRANSACTION_FINALIZER=archive_remove_artifacts
|
||||
if run_transaction remove "$name" remove_peer_builder "$REMOVE_META_FILE"; then status=0; else status=$?; fi
|
||||
if (( status == 0 )); then
|
||||
[[ -n ${REMOVE_ARCHIVE_PATH:-} ]] && REMOVE_ARCHIVE_RESULT=$REMOVE_ARCHIVE_PATH
|
||||
REMOVE_ERROR_CATEGORY=success
|
||||
if ! remove_audit success; then printf 'Предупреждение: удаление выполнено, но не удалось записать аудит (частичный результат)\n' >&2; status=$EXIT_OPERATION; fi
|
||||
elif { REMOVE_ERROR_CATEGORY=transaction_failed; ! remove_audit failed; }; then
|
||||
elif { REMOVE_ERROR_CATEGORY=transaction-failed; ! remove_audit failed; }; then
|
||||
printf 'Предупреждение: операция удаления завершилась ошибкой, аудит не записан\n' >&2
|
||||
fi
|
||||
unset REMOVE_NAME REMOVE_KEY REMOVE_CONFIG_PATH REMOVE_META_FILE REMOVE_ALLOWED REMOVE_ARCHIVE_PATH TRANSACTION_FINALIZER
|
||||
return "$status"
|
||||
}
|
||||
|
||||
remove_client_impl() {
|
||||
local name='' flag='' arg
|
||||
if (($# > 0)); then name=$1; shift; fi
|
||||
while (($#)); do
|
||||
arg=$1
|
||||
case $arg in
|
||||
--yes) [[ -z $flag ]] || { die "$EXIT_USAGE" 'Флаг remove --yes указан повторно'; return $?; }; flag=--yes ;;
|
||||
*) die "$EXIT_USAGE" 'Лишние аргументы remove'; return $? ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
remove_client_core "$name" '' "$flag"
|
||||
}
|
||||
|
||||
remove_client() {
|
||||
local xtrace=0 status
|
||||
[[ $- == *x* ]] && xtrace=1; set +x
|
||||
@@ -2259,12 +2636,21 @@ remove_client() {
|
||||
return "$status"
|
||||
}
|
||||
|
||||
remove_client_exact() {
|
||||
local name=$1 expected_key=$2 status xtrace=0
|
||||
[[ $- == *x* ]] && xtrace=1
|
||||
set +x
|
||||
if remove_client_core "$name" "$expected_key" --yes; then status=0; else status=$?; fi
|
||||
(( xtrace == 1 )) && set -x
|
||||
return "$status"
|
||||
}
|
||||
|
||||
main() {
|
||||
local status
|
||||
if (( $# == 0 )); then
|
||||
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 +2709,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
|
||||
|
||||
Reference in New Issue
Block a user