feat(tui): add safe client and backup workflows

This commit is contained in:
2026-10-15 20:58:17 +03:00
parent 06c7171e91
commit a4f530d7a5
5 changed files with 592 additions and 286 deletions
+15 -1
View File
@@ -271,7 +271,7 @@ Allowed status values: `NOT STARTED`, `IN PROGRESS`, `BLOCKED`, `DONE`.
| 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 | NOT STARTED | Hardening, documentation, and MVP acceptance |
| M11 | DONE | Hardening, documentation, and MVP acceptance |
---
@@ -1705,6 +1705,20 @@ Use this template:
- 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:
+196 -89
View File
@@ -1,118 +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`, optional `--split-networks CIDRS` for
split-tunnel, and `--endpoint HOST`; unknown or extra
arguments are rejected. Names are restricted to ASCII letters, digits, `.`,
`_`, and `-` (without a leading `-`). Root is required for production use;
tests enable only an isolated `WG_MANAGER_TEST_MODE` under `TEST_ROOT`.
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.
## M9–M10 TUI
## Troubleshooting
Запуск без аргументов или командой `tui` открывает интерфейс `gum` в
терминале. `gum` разрешается в порядке `WG_MANAGER_GUM`, локального `bin/gum`,
затем `PATH`; необходимые подкоманды проверяются до вывода интерфейса. Нужны
одновременно интерактивные stdin и stdout. Escape и отмена возвращают на
предыдущий экран или завершают меню без изменений. Доступны status, clients/show,
diagnose, log и backups. В M10 также доступны add/remove, создание backup и
restore через те же backend-функции, что и CLI; подтверждение и отмена остаются
обязательными для мутаций, а критический rollback завершает TUI с ненулевым кодом.
Для клиента сначала показываются безопасные детали; конфигурация и QR требуют
отдельного явного действия, при отсутствии `qrencode` показывается понятный
fallback. CLI работает независимо от `gum`; отсутствие или неполная
возможность `gum` даёт код `69` только для TUI, а неинтерактивный запуск TUI —
код `64`. Восстановление требует ввода полного идентификатора выбранной копии;
несовпадение или отмена не изменяют состояние.
- `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`.
## M6 удаление клиента
## M11 evidence matrix
`remove NAME [--yes]` удаляет только подтверждённый managed-peer после повторной проверки точного публичного ключа, адресов и блока под lock. В интерактивном терминале без `--yes` показываются имя, адреса и только последние 8 символов публичного ключа; ответ, отличный от `y`, отменяет операцию без изменений. В неинтерактивном режиме `--yes` обязателен.
| Требование | 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 остаётся ручным шагом |
Изменение серверной конфигурации выполняется внутри транзакции: сначала конфигурация заменяется и проверяется в WireGuard, а затем финализатор архивирует связанные `.conf`, `.meta`, `.key`, `.pub` и `.psk` (если существуют) в уникальный каталог `ARCHIVE_DIR` с режимами `0700`/`0600`. Ошибка архивирования после применения приводит к откату и оставляет исходные файлы доступными для восстановления; архивы не удаляются автоматически и служат восстановительным материалом. Журнал содержит operation, target, UTC timestamp, outcome и короткий суффикс публичного ключа; ключи и содержимое конфигураций в него не записываются.
Это offline evidence, а не обещание, что live server уже настроен или что
deployment checklist выполнен.
`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 и не доказывают состояние реального сервера.
## Disposable deployment checklist (не выполнен)
## M8 резервные копии, восстановление и аудит
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.
`backup` создаёт ручную резервную копию текущей серверной конфигурации под lock; `backups` показывает только проверенные прямые дочерние каталоги, в порядке от новых к старым. Повреждённые или неполные записи не исполняются и не читаются как shell-код. `restore BACKUP [--yes]` восстанавливает только серверную конфигурацию: перед заменой текущая версия также резервируется, а ошибки применения проверяют и откатывают disk/runtime. Восстановление другой WireGuard-интерфейсной конфигурации отклоняется. Без `--yes` требуется подтверждение в терминале; в неинтерактивном режиме команда возвращает ошибку использования. Клиентские metadata/key-файлы восстановлением не изменяются.
`log [LIMIT]` выводит только корректные структурированные audit-события в newest-first порядке; `LIMIT` находится в диапазоне 1..1000. При отсутствии журнала выводится безопасное сообщение. Записи содержат UTC timestamp, actor UID, operation, target, outcome и короткую категорию ошибки; секреты, команды и полные конфигурации не записываются. Ошибки аудита после успешной мутации сообщаются как частичный операционный результат. Основные коды: `64` — аргументы, `65` — небезопасная/невалидная backup identity, `70` — ошибка операции; критический неудачный rollback сохраняет отдельный rollback status.
Этот список намеренно не выполняется агентом и не является разрешением менять
реальный host state без отдельной проверки оператором.
+22 -191
View File
@@ -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
add_user_main() {
local -a args
if (( $# < 1 || $# > 2 )); then
add_user_usage
return 64
fi
done
return 1
args=(add "$1")
if (( $# == 2 )); then
args+=(--endpoint "$2")
fi
exec "${ADD_USER_SCRIPT_DIR}/wg-manager.sh" "${args[@]}"
}
CLIENT_IPV4="$(find_next_ipv4)" || die "Не удалось найти свободный IPv4 адрес"
CLIENT_HOST_OCTET="${CLIENT_IPV4##*.}"
if [[ "$USE_IPV6" == "1" ]]; then
CLIENT_IPV6="${IPV6_PREFIX}${CLIENT_HOST_OCTET}"
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
add_user_main "$@"
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"
fi
} > "$TMP_PEER_FILE"
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"
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}"
+345 -3
View File
@@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh"
case $# in
0) ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes|gum-resolution|tui-routing|tui-cancel|tui-read-only|tui-no-gum|tui-non-tty|tui-add|tui-remove|tui-client-actions|tui-backup|tui-restore|tui-backend-failure|tui-no-secret-args) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes|gum-resolution|tui-routing|tui-cancel|tui-read-only|tui-no-gum|tui-non-tty|tui-add|tui-remove|tui-client-actions|tui-backup|tui-restore|tui-backend-failure|tui-no-secret-args|symlink-safety|no-host-writes|no-host-command-bypass|m11-e2e|secret-scan) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
esac
test_group=${1-}
@@ -104,6 +104,11 @@ run_config_tests() {
assert_equal "$TEST_ROOT/archive" "$ARCHIVE_DIR" 'путь архива'
assert_equal "$TEST_ROOT/manager.lock" "$LOCK_FILE" 'путь lock-файла'
begin_test 'Bash version guard has explicit supported and rejected branches'
assert_equal 0 "$(wg_manager_bash_supported 4 3 4 3; printf '%s' "$?")" 'supported Bash version'
assert_failure "$(wg_manager_bash_supported 4 3 4 2; printf '%s' "$?")" 'old Bash minor version'
assert_failure "$(wg_manager_bash_supported 4 3 3 9; printf '%s' "$?")" 'old Bash major version'
begin_test 'test hooks require a confined non-symlink root and paths'
assert_equal 1 "$(TEST_ROOT=/ WG_MANAGER_TEST_MODE=1 CONFIG_PATH=/tmp/x transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'TEST_ROOT=/ отклонён'
assert_equal 1 "$(TEST_ROOT=relative WG_MANAGER_TEST_MODE=1 CONFIG_PATH=relative transaction_test_hooks_enabled >/dev/null 2>&1; printf '%s' "$?")" 'относительный TEST_ROOT отклонён'
@@ -434,7 +439,7 @@ prepare_m10_add_fixture() {
cp -- "$runtime_file" "${TEST_ROOT}/dump-sequence/6"
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n' >"${TEST_ROOT}/dump-sequence/1"
export WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_DUMP_SEQUENCE_DIR="${TEST_ROOT}/dump-sequence"
unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK WG_MOCK_EXPECTED_PRIVATE_KEY
unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_PRIVATE_KEY WG_MOCK_PUBLIC_KEY WG_MOCK_PSK WG_MOCK_EXPECTED_PRIVATE_KEY TRANSACTION_ROLLBACK_REPLACE_FAIL
set_runtime_paths
load_config
rm -rf -- "$METADATA_DIR" "$CLIENT_DIR"
@@ -4080,7 +4085,339 @@ run_m7_tests() {
fi
}
if [[ $test_group == config ]]; then
run_m11_tests() {
local config_file="$TEST_ROOT/m11.conf" fixture="$TEST_ROOT/m11-wg0.conf"
local output status before_tree after_tree outside sentinel
local initial_hash baseline_backup baseline_id m11_dump failed_hash
cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$fixture"
chmod 600 -- "$fixture"
write_m2_config "$config_file" "$fixture"
replace_config_line "$config_file" 'CLIENT_DIR=' "CLIENT_DIR=\"$TEST_ROOT/m11-clients\""
export WG_MANAGER_TEST_MODE=1 WG_MANAGER_CONFIG="$config_file"
export WG_MANAGER_BACKUP_DIR="$TEST_ROOT/m11-backups" WG_MANAGER_LOG_DIR="$TEST_ROOT/m11-logs"
export WG_MANAGER_METADATA_DIR="$TEST_ROOT/m11-meta" WG_MANAGER_ARCHIVE_DIR="$TEST_ROOT/m11-archive"
export WG_MANAGER_LOCK_FILE="$TEST_ROOT/m11.lock" WG_MOCK_INTERFACE=wg0
export WG_MOCK_DUMP_FILE="$PROJECT_DIR/tests/fixtures/m3/runtime.dump"
unset WG_MANAGER_GUM GUM_SCRIPT_FILE WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE
set_runtime_paths
load_config
rm -rf -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR"
mkdir -p -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR"
chmod 700 -- "$CLIENT_DIR" "$METADATA_DIR" "$BACKUP_DIR" "$LOG_DIR" "$ARCHIVE_DIR"
: >"$TEST_MOCK_LOG"
case $test_group in
symlink-safety)
begin_test 'M11 rejects symlinked runtime/config targets without following them'
outside="$TEST_ROOT/m11-outside"; mkdir -p -- "$outside"
sentinel="$outside/sentinel"; printf 'unchanged\n' >"$sentinel"
mv -- "$config_file" "$TEST_ROOT/m11-manager-real.conf"
ln -s -- "$TEST_ROOT/m11-manager-real.conf" "$config_file"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e
assert_contains 'config-readable=FAIL' "$output" 'symlinked manager config is rejected'
assert_equal unchanged "$(<"$sentinel")" 'symlink target sentinel remains unchanged'
rm -f -- "$config_file"; mv -- "$TEST_ROOT/m11-manager-real.conf" "$config_file"
mv -- "$BACKUP_DIR" "$TEST_ROOT/m11-backups-real"
ln -s -- "$outside" "$BACKUP_DIR"
set +e; output=$(manual_backup 2>&1); status=$?; set -e
assert_failure "$status" 'symlinked backup directory is rejected'
assert_equal unchanged "$(<"$sentinel")" 'backup symlink target remains unchanged'
rm -f -- "$BACKUP_DIR"; mv -- "$TEST_ROOT/m11-backups-real" "$BACKUP_DIR"
;;
no-host-writes)
begin_test 'M11 isolated lifecycle does not write the sandbox sibling or repository'
local operation_root sibling_root source_snapshot repo_runtime_before repo_runtime_after
operation_root="$TEST_ROOT/operation-root"; sibling_root="$TEST_ROOT/sibling-root"
mkdir -p -- "$operation_root" "$sibling_root/keep"
printf 'sentinel-before\n' >"$sibling_root/keep/sentinel"
cp -- "$PROJECT_DIR/tests/fixtures/m3/source.conf" "$operation_root/wg0.conf"
write_m2_config "$operation_root/m11.conf" "$operation_root/wg0.conf"
replace_config_line "$operation_root/m11.conf" 'CLIENT_DIR=' "CLIENT_DIR=\"$operation_root/clients\""
replace_config_line "$operation_root/m11.conf" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="3"'
export WG_MANAGER_CONFIG="$operation_root/m11.conf" WG_MANAGER_BACKUP_DIR="$operation_root/backups" WG_MANAGER_LOG_DIR="$operation_root/logs" WG_MANAGER_METADATA_DIR="$operation_root/metadata" WG_MANAGER_ARCHIVE_DIR="$operation_root/archive" WG_MANAGER_LOCK_FILE="$operation_root/lock"
cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$operation_root/runtime.dump"
mkdir -p -- "$operation_root/dumps"
cp -- "$operation_root/runtime.dump" "$operation_root/dumps/1"
for candidate in 3 4 5 6; do cp -- "$operation_root/runtime.dump" "$operation_root/dumps/$candidate"; done
{ cat "$operation_root/runtime.dump"; printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n'; } >"$operation_root/dumps/2"
export WG_MOCK_DUMP_FILE="$operation_root/runtime.dump" WG_MOCK_DUMP_SEQUENCE_DIR="$operation_root/dumps"
mkdir -p -- "$operation_root/clients" "$operation_root/backups" "$operation_root/logs" "$operation_root/metadata" "$operation_root/archive"
chmod 700 -- "$operation_root/clients" "$operation_root/backups" "$operation_root/logs" "$operation_root/metadata" "$operation_root/archive"
load_config
source_snapshot=$(sha256sum "$sibling_root/keep/sentinel")
repo_runtime_before=$(find "$PROJECT_DIR" -maxdepth 1 -name '.wg-manager.*' -o -name 'wg-manager.log' | LC_ALL=C sort)
set +e; bash "$PROJECT_DIR/wg-manager.sh" status >/dev/null 2>/dev/null; status=$?; set -e
assert_equal 0 "$status" 'isolated status succeeds'
set +e; add_client sandbox-client >/dev/null 2>"$operation_root/add.err"; status=$?; set -e
assert_equal 0 "$status" 'isolated real mutation succeeds'
assert_equal "$source_snapshot" "$(sha256sum "$sibling_root/keep/sentinel")" 'sandbox sibling is unchanged'
assert_equal '' "$(find "$operation_root" -path "$operation_root/*" ! -path "$operation_root/clients/*" ! -path "$operation_root/backups/*" ! -path "$operation_root/logs/*" ! -path "$operation_root/metadata/*" ! -path "$operation_root/archive/*" -type f -name '*wg-manager*' -print)" 'temporary mutation files stay in operation root'
repo_runtime_after=$(find "$PROJECT_DIR" -maxdepth 1 -name '.wg-manager.*' -o -name 'wg-manager.log' | LC_ALL=C sort)
assert_equal "$repo_runtime_before" "$repo_runtime_after" 'repository has no runtime files'
assert_equal '' "$(grep -E '(/etc|/proc|/sys)' "$TEST_MOCK_LOG" || true)" 'mock log contains no host state path'
assert_equal '' "$(grep -E 'wg-quick (up|down|save)|ip (add|del|replace)|systemctl (start|stop|restart)|nft (add|delete|insert|flush)|iptables (-A|-D|-I|-F)|sysctl -w' "$TEST_MOCK_LOG" || true)" 'lifecycle uses no host mutation commands'
local forbidden_host_path="/$(printf 'etc')/$(printf 'wireguard')"
assert_equal '' "$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' "${forbidden_host_path}|/bin/(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|ss)|env -i|bash -c|sh -c|eval[[:space:]]" "$PROJECT_DIR" || true)" 'production has no unscoped host command bypass'
;;
no-host-command-bypass)
begin_test 'M11 sensitive commands resolve to isolated mocks and execute through them'
local failures_before=$TEST_FAILURES command_name candidate path line command_dir safe_dir resolved saved_path bypass_output
command_dir="$TESTS_DIR/mocks/bin"; safe_dir="$TEST_ROOT/safe-bin"; mkdir -p -- "$safe_dir"
for candidate in awk bash cat chmod cp cut date find grep head mkdir mktemp mv paste rg rm sed sha256sum sort stat tail tr; do
ln -s -- "$(command -v "$candidate")" "$safe_dir/$candidate"
done
local bypass_path="$command_dir:$safe_dir"
for command_name in wg wg-quick ip systemctl journalctl ss nft iptables qrencode gum; do
resolved=$(PATH="$bypass_path" command -v "$command_name" || true)
assert_equal "$command_dir/$command_name" "$resolved" "${command_name} resolves to isolated mock"
done
: >"$TEST_MOCK_LOG"
saved_path=$PATH
PATH="$bypass_path"; export PATH
set +e; bypass_output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e
assert_equal 0 "$status" 'controlled PATH status succeeds'
set +e; bypass_output=$(bash "$PROJECT_DIR/wg-manager.sh" diagnose --quiet 2>&1); status=$?; set -e
assert_equal 0 "$status" 'controlled PATH diagnose succeeds with isolated mocks'
mkdir -p -- "$TEST_ROOT/bypass-dumps"
cp -- "$PROJECT_DIR/tests/fixtures/m3/runtime.dump" "$TEST_ROOT/bypass-dumps/1"
cp -- "$TEST_ROOT/bypass-dumps/1" "$TEST_ROOT/bypass-dumps/3"
{ cat "$TEST_ROOT/bypass-dumps/1"; printf 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\t(none)\tvpn.example.invalid:51820\t192.0.2.3/32\t0\t0\t0\t0\n'; } >"$TEST_ROOT/bypass-dumps/2"
export WG_MOCK_DUMP_SEQUENCE_DIR='' WG_MOCK_DUMP_FILE="$TEST_ROOT/bypass-dumps/1" WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/bypass-sync-fail"
set +e; bypass_output=$(add_client bypass-client 2>&1); status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'controlled PATH real add failure is contained'
PATH=$saved_path; export PATH
assert_contains 'wg show wg0 dump' "$(<"$TEST_MOCK_LOG")" 'controlled mock recorded wg runtime access'
assert_contains 'wg syncconf wg0' "$(<"$TEST_MOCK_LOG")" 'controlled mock recorded wg mutation apply'
for command_name in wg wg-quick ip systemctl journalctl nft iptables sysctl; do
for candidate in "/bin/${command_name}" "/usr/bin/${command_name}"; do
for path in "$PROJECT_DIR/wg-manager.sh" "$TESTS_DIR"/*.sh "$TESTS_DIR"/mocks/bin/*; do
[[ -f "$path" ]] || continue
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" == "$candidate" || "$line" == *" $candidate"* || "$line" == *"=\"$candidate"* ]]; then
record_failure "absolute host command in ${path}: ${candidate}"
break
fi
done <"$path"
done
done
done
assert_equal "$failures_before" "$TEST_FAILURES" 'no absolute host networking/service command'
assert_equal '' "$(grep -E '^(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|sysctl|ss|qrencode|gum) ' "$TEST_MOCK_LOG" | grep -E '(/etc|/proc|/sys)' || true)" 'mock argv has no host-state path'
assert_equal '' "$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' 'env -i|bash -c|sh -c|eval[[:space:]]|(^|[[:space:]])/(bin|usr/bin)/(wg|wg-quick|ip|systemctl|journalctl|nft|iptables|ss|qrencode|gum)([[:space:]]|$)' "$PROJECT_DIR" || true)" 'no direct command bypass forms'
;;
m11-e2e)
begin_test 'M11 fixture matrix covers fresh, mixed, stopped, and malformed states'
cp -- "$PROJECT_DIR/tests/fixtures/m2/no-peers.conf" "$fixture"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e
assert_equal 0 "$status" 'fresh server with no peers is readable'
cp -- "$PROJECT_DIR/tests/fixtures/m2/managed.conf" "$fixture"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e
assert_equal 0 "$status" 'mixed/managed fixture remains readable without mutation'
export WG_MOCK_DUMP_FAIL_ON_CALL=1
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" status 2>&1); status=$?; set -e
assert_equal 0 "$status" 'stopped runtime degrades to disk-only status'
unset WG_MOCK_DUMP_FAIL_ON_CALL
cp -- "$PROJECT_DIR/tests/fixtures/m2/malformed.conf" "$fixture"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e
assert_equal "$EXIT_VALIDATION" "$status" 'malformed persistent config fails closed'
begin_test 'M11 compatibility wrapper is source-safe and delegates one backend'
set +e; output=$(bash "$PROJECT_DIR/add-user.sh" 2>&1); status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'legacy wrapper rejects missing name'
assert_contains 'Устаревший wrapper' "$output" 'legacy wrapper is documented as compatibility path'
assert_equal '' "$(grep -E '/etc|37\.26\.136\.232|PrivateKey|PresharedKey' <<<"$output" || true)" 'wrapper output has no host path or secret'
begin_test 'M11 wrapper is source-safe and forwards an exact argument vector'
local wrapper_dir wrapper_capture probe_output fake_wrapper
wrapper_dir="$TEST_ROOT/wrapper"; wrapper_capture="$TEST_ROOT/wrapper-argv"
mkdir -- "$wrapper_dir"
cp -- "$PROJECT_DIR/add-user.sh" "$wrapper_dir/add-user.sh"
fake_wrapper="$wrapper_dir/wg-manager.sh"
printf '#!/usr/bin/env bash\nprintf "%%s\\0" "$@" >%q\n' "$wrapper_capture" >"$fake_wrapper"
chmod 700 -- "$fake_wrapper" "$wrapper_dir/add-user.sh"
probe_output=$(printf 'before\n'; main() { printf 'caller-main\n'; }; usage() { printf 'caller-usage\n'; }; source "$wrapper_dir/add-user.sh"; main; usage; printf 'after\n')
assert_equal $'before\ncaller-main\ncaller-usage\nafter' "$probe_output" 'sourcing wrapper preserves caller functions'
"$wrapper_dir/add-user.sh" alice vpn.example.invalid
local -a wrapper_args=()
mapfile -d '' -t wrapper_args <"$wrapper_capture"
assert_equal 4 "${#wrapper_args[@]}" 'wrapper forwards four arguments'
assert_equal add "${wrapper_args[0]}" 'wrapper command argument'
assert_equal alice "${wrapper_args[1]}" 'wrapper name argument'
assert_equal --endpoint "${wrapper_args[2]}" 'wrapper endpoint flag'
assert_equal vpn.example.invalid "${wrapper_args[3]}" 'wrapper endpoint value'
set +e; "$wrapper_dir/add-user.sh" >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'wrapper rejects missing name'
set +e; "$wrapper_dir/add-user.sh" one two three >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'wrapper rejects extra arguments'
"$wrapper_dir/add-user.sh" $'-unsafe\nname'
wrapper_args=(); mapfile -d '' -t wrapper_args <"$wrapper_capture"
assert_equal $'-unsafe\nname' "${wrapper_args[1]}" 'wrapper forwards option-like newline name literally'
begin_test 'M11 real add backend produces an inspectable isolated client'
prepare_m10_add_fixture
set +e; add_client m11-client >/dev/null 2>"$TEST_ROOT/m11-add.err"; status=$?; set -e
assert_equal 0 "$status" 'real M11 add succeeds'
assert_file_exists "$CLIENT_DIR/m11-client.conf" 'real M11 client config exists'
assert_file_exists "$METADATA_DIR/m11-client.meta" 'real M11 metadata exists'
set +e; output=$(export_client_config m11-client 0 2>&1); status=$?; set -e
assert_equal 0 "$status" 'real M11 config path is validated'
assert_contains 'm11-client.conf' "$output" 'real M11 config path is shown'
unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/1"
local m11_no_qr utility
m11_no_qr="$TEST_ROOT/m11-no-qr"; mkdir -- "$m11_no_qr"
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg" "$m11_no_qr/wg"
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg-quick" "$m11_no_qr/wg-quick"
for utility in stat sort date cat paste grep awk; do ln -s -- "$(command -v "$utility")" "$m11_no_qr/$utility"; done
local m11_saved_path=$PATH
PATH="$m11_no_qr"; export PATH
set +e; output=$(qr_client m11-client 2>&1); status=$?; set -e
PATH=$m11_saved_path; export PATH
assert_equal "$EXIT_DEPENDENCY" "$status" 'M11 QR fallback reports missing optional dependency'
assert_contains 'm11-client.conf' "$output" 'M11 QR fallback exposes only safe path'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_ROOT/m11-add.err" "$TEST_MOCK_LOG" "$LOG_DIR/wg-manager.log" || true)" 'M11 lifecycle diagnostics remain secret-free'
begin_test 'M11 real lifecycle add, inspect, remove, and restore returns to baseline'
# The add fixture is the same isolated model used by the production M4
# tests. Keep the complete lifecycle in one process so every transition
# is verified against the preceding state rather than a stubbed callback.
prepare_m10_add_fixture
initial_hash=$(sha256sum "$WG_CONF")
baseline_backup=$(create_backup "$WG_CONF" m11 "$WG_INTERFACE")
baseline_id=${baseline_backup##*/}
set +e; add_client m11-client >/dev/null 2>"$TEST_ROOT/m11-lifecycle-add.err"; status=$?; set -e
assert_equal 0 "$status" 'lifecycle add succeeds'
assert_file_exists "$CLIENT_DIR/m11-client.conf" 'lifecycle client config published'
assert_file_exists "$METADATA_DIR/m11-client.meta" 'lifecycle metadata published'
assert_equal 600 "$(stat -c '%a' "$CLIENT_DIR/m11-client.conf")" 'lifecycle config mode'
assert_equal 600 "$(stat -c '%a' "$METADATA_DIR/m11-client.meta")" 'lifecycle metadata mode'
assert_contains '192.0.2.3/32' "$(<"$WG_CONF")" 'lifecycle server peer address'
assert_contains 'operation=add' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle add audit'
# Both the original fixture peer and the newly managed peer are present;
# assert their independent origins before removing the managed one.
m11_dump="$TEST_ROOT/m11-after-add.dump"
cat >"$m11_dump" <<'EOF'
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= 51820 0
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 0 0 0 0
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= (none) vpn.example.invalid:51820 192.0.2.3/32 0 0 0 0
EOF
export WG_MOCK_DUMP_FILE="$m11_dump"; unset WG_MOCK_DUMP_SEQUENCE_DIR
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e
assert_equal 0 "$status" 'lifecycle mixed model is readable'
assert_contains $'\tmanaged' "$output" 'lifecycle managed origin'
assert_contains $'\tunmanaged' "$output" 'lifecycle unmanaged origin'
assert_contains '192.0.2.3/32' "$output" 'lifecycle managed address is distinct'
assert_contains '192.0.2.2/32' "$output" 'lifecycle unmanaged address is distinct'
# The runtime state after removal is the baseline (the managed peer is
# absent); keep this separate from the add-state dump used for listing.
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\tAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t0\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\t(none)\tvpn.example.invalid:51820\t192.0.2.2/32\t0\t0\t0\t0\n' >"$TEST_ROOT/m11-after-remove.dump"
export WG_MOCK_DUMP_FILE="$TEST_ROOT/m11-after-remove.dump"
# Remove by the authoritative public key, then verify the exact source
# bytes and archive rather than merely checking a return status.
set +e; output=$(remove_client_exact m11-client FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF= 2>"$TEST_ROOT/m11-lifecycle-remove.err"); status=$?; set -e
assert_equal 0 "$status" 'lifecycle remove succeeds'
assert_equal 0 "$(grep -c '192.0.2.3/32' "$WG_CONF" || true)" 'lifecycle remove removes managed peer address'
assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -type f -name 'm11-client.*' | wc -l)" 'lifecycle remove deletes source artifacts'
assert_equal 1 "$(find "$ARCHIVE_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)" 'lifecycle remove creates one archive'
assert_contains 'operation=remove' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle remove audit'
# Restore the pre-add snapshot through the real restore backend. The
# runtime fixture is the baseline dump, so verification also covers the
# current-state backup created by restore.
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"
set +e; output=$(restore_backup "$baseline_id" --yes 2>"$TEST_ROOT/m11-lifecycle-restore.err"); status=$?; set -e
assert_equal 0 "$status" 'lifecycle restore succeeds'
assert_equal "$initial_hash" "$(sha256sum "$WG_CONF")" 'lifecycle restore is byte-identical to baseline'
assert_equal 1 "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "$baseline_id" | wc -l)" 'lifecycle source backup remains valid'
assert_contains 'operation=restore' "$(<"$LOG_DIR/wg-manager.log")" 'lifecycle restore audit'
assert_contains 'syncconf' "$(<"$TEST_MOCK_LOG")" 'lifecycle restore applies runtime state'
begin_test 'M11 real add rollback and failed rollback are distinct'
prepare_m10_add_fixture
failed_hash=$(sha256sum "$WG_CONF")
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m11-sync-fail"
set +e; add_client m11-failed >/dev/null 2>"$TEST_ROOT/m11-failed.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'lifecycle apply failure rolls back successfully'
assert_equal "$failed_hash" "$(sha256sum "$WG_CONF")" 'successful rollback preserves disk'
assert_equal 0 "$(find "$CLIENT_DIR" "$METADATA_DIR" -maxdepth 1 -type f -name 'm11-failed.*' | wc -l)" 'successful rollback removes client artifacts'
assert_contains 'operation=rollback' "$(<"$LOG_DIR/wg-manager.log")" 'successful rollback is audited'
prepare_m10_add_fixture
failed_hash=$(sha256sum "$WG_CONF")
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_SYNC_FAIL_ONCE_FILE="$TEST_ROOT/m11-critical-sync" TRANSACTION_ROLLBACK_REPLACE_FAIL=1
set +e; add_client m11-critical >/dev/null 2>"$TEST_ROOT/m11-critical.err"; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL
assert_equal "$EXIT_ROLLBACK" "$status" 'failed rollback is critical'
assert_equal 1 "$(test -f "$WG_CONF" && printf 1 || printf 0)" 'failed rollback keeps a live source file'
assert_contains 'КРИТИЧНО' "$(<"$TEST_ROOT/m11-critical.err")" 'failed rollback diagnostic is critical'
begin_test 'M11 diagnostics skip unavailable optional capabilities'
prepare_m10_add_fixture
printf '1\n' >"$TEST_ROOT/m11-ipv4-forward"
printf '1\n' >"$TEST_ROOT/m11-ipv6-forward"
export WG_MANAGER_IPV4_FORWARD_FILE="$TEST_ROOT/m11-ipv4-forward" WG_MANAGER_IPV6_FORWARD_FILE="$TEST_ROOT/m11-ipv6-forward" DIAG_DISABLE_COMMANDS='systemctl,journalctl,nft,iptables,ss'
set +e; output=$(diagnose --quiet 2>&1); status=$?; set -e
unset DIAG_DISABLE_COMMANDS
assert_equal 0 "$status" 'optional diagnostic capabilities degrade safely'
assert_contains 'systemd=SKIP' "$output" 'systemd unavailable is skipped'
assert_contains 'journal=SKIP' "$output" 'journal unavailable is skipped'
assert_contains 'udp-listener=SKIP' "$output" 'ss unavailable is skipped'
assert_contains 'firewall=SKIP' "$output" 'firewall unavailable is skipped'
begin_test 'M11 malformed metadata is inert for read-only inspection'
printf 'UNKNOWN=value\n' >"$METADATA_DIR/bad.meta"; chmod 600 -- "$METADATA_DIR/bad.meta"
set +e; output=$(bash "$PROJECT_DIR/wg-manager.sh" list 2>&1); status=$?; set -e
assert_failure "$status" 'malformed metadata fails closed'
assert_equal 0 "$(grep -c 'UNKNOWN=value' "$TEST_MOCK_LOG" || true)" 'metadata text never reaches mock argv'
;;
secret-scan)
begin_test 'M11 repository and lifecycle secret scan'
local repository_hits lifecycle_output secret_token secret_tokens forbidden_endpoint
local forbidden_host_path="/$(printf 'etc')/$(printf 'wireguard')"
repository_hits=$(rg -n --glob 'wg-manager.sh' --glob 'add-user.sh' --glob 'README.md' "37\\.26\\.136\\.232|${forbidden_host_path}" "$PROJECT_DIR" || true)
assert_equal '' "$repository_hits" 'production/docs contain no host endpoint or live WireGuard path'
local production_keys
production_keys=$(rg -o --glob 'wg-manager.sh' --glob 'add-user.sh' --glob 'README.md' '[A-Za-z0-9+/]{43}=' "$PROJECT_DIR" || true)
assert_equal '' "$production_keys" 'production and docs contain no key-shaped literals'
secret_tokens=$(rg -o --glob '!/.git/**' --glob '!*.md' '[A-Za-z0-9+/]{43}=' "$PROJECT_DIR" | sed 's/.*://' | sort -u || true)
local unexpected_token=0
while IFS= read -r secret_token; do
[[ -z $secret_token ]] && continue
case $secret_token in
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=|CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=|DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=|EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=|FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=|GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=|HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=) ;;
*) unexpected_token=1 ;;
esac
done <<<"$secret_tokens"
assert_equal 0 "$unexpected_token" 'repository key-shaped values are documented placeholders only'
forbidden_endpoint=$(rg -n '37\\.26\\.136\\.232' "$PROJECT_DIR/wg-manager.sh" "$PROJECT_DIR/add-user.sh" "$PROJECT_DIR/README.md" "$PROJECT_DIR/tests/fixtures" || true)
assert_equal '' "$forbidden_endpoint" 'repository contains no removed public endpoint'
lifecycle_output=$(bash "$PROJECT_DIR/wg-manager.sh" help 2>&1)
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|[A-Za-z0-9+/]{43}=' <<<"$lifecycle_output" || true)" 'captured CLI output has no key material'
assert_equal '' "$(grep -E "37\\.26\\.136\\.232|${forbidden_host_path}" "$TEST_MOCK_LOG" || true)" 'mock log has no host paths'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$TEST_MOCK_LOG" || true)" 'mock log has no secret markers'
begin_test 'M11 captured real lifecycle output is secret-free'
prepare_m10_add_fixture
set +e; lifecycle_output=$(add_client secret-scan 2>"$TEST_ROOT/secret-add.err"); status=$?; set -e
assert_equal 0 "$status" 'secret scan real add succeeds'
set +e; lifecycle_output+=$'\n'"$(show_client secret-scan 2>"$TEST_ROOT/secret-show.err")"; status=$?; set -e
assert_equal 0 "$status" 'secret scan real show succeeds'
export WG_MOCK_DUMP_FILE="$TEST_ROOT/dump-sequence/2"; unset WG_MOCK_DUMP_SEQUENCE_DIR WG_SYNC_FAIL_ONCE_FILE
set +e; add_client secret-failed >/dev/null 2>"$TEST_ROOT/secret-fail.err"; status=$?; set -e
local captured_secret_stream
captured_secret_stream=$(printf '%s\n' "$lifecycle_output" "$(<"$TEST_ROOT/secret-add.err")" "$(<"$TEST_ROOT/secret-show.err")" "$(<"$TEST_ROOT/secret-fail.err")" "$(<"$LOG_DIR/wg-manager.log")" "$(<"$TEST_MOCK_LOG")")
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG|\[Interface\]|\[Peer\]' <<<"$captured_secret_stream" || true)" 'ordinary lifecycle output contains no secret/config markers'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|EEEE|GGGG' "$LOG_DIR/wg-manager.log" "$TEST_MOCK_LOG" || true)" 'lifecycle audit and mocks contain no private/PSK values'
;;
esac
}
if [[ $test_group == symlink-safety || $test_group == no-host-writes || $test_group == no-host-command-bypass || $test_group == m11-e2e || $test_group == secret-scan ]]; then
run_m11_tests
elif [[ $test_group == config ]]; then
run_config_tests
elif [[ $test_group == show-client || $test_group == config-export || $test_group == qr || $test_group == path-safety || $test_group == no-secret-default-output ]]; then
run_m5_tests
@@ -4126,6 +4463,11 @@ else
run_m9_tests
done
unset m9_group
for m11_group in symlink-safety no-host-writes no-host-command-bypass m11-e2e secret-scan; do
test_group=$m11_group
run_m11_tests
done
unset m11_group
test_group=''
fi
+13 -1
View File
@@ -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
@@ -56,7 +66,9 @@ print_usage() {
wg-manager.sh version
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
}