From a4f530d7a512dffc7771bfdd70d10dc18c8758ba Mon Sep 17 00:00:00 2001 From: sasa Date: Thu, 15 Oct 2026 20:58:17 +0300 Subject: [PATCH] feat(tui): add safe client and backup workflows --- PLANS.md | 16 ++- README.md | 285 ++++++++++++++++++++++++++++------------- add-user.sh | 215 ++++--------------------------- tests/run.sh | 348 +++++++++++++++++++++++++++++++++++++++++++++++++- wg-manager.sh | 14 +- 5 files changed, 592 insertions(+), 286 deletions(-) diff --git a/PLANS.md b/PLANS.md index 15ebb68..4d94506 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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: diff --git a/README.md b/README.md index d804630..33a320d 100644 --- a/README.md +++ b/README.md @@ -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 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 без отдельной проверки оператором. diff --git a/add-user.sh b/add-user.sh index c8d016c..0a58529 100755 --- a/add-user.sh +++ b/add-user.sh @@ -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 < "$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" </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}" diff --git a/tests/run.sh b/tests/run.sh index 8809884..3ab52df 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh" case $# in 0) ;; - 1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes|add-client|address-allocation|add-collisions|add-rollback|no-secret-logs|add-golden-config|add-invalid-names|show-client|config-export|qr|path-safety|no-secret-default-output|remove-client|remove-cancel|remove-exact-block|remove-rollback|archive-client|archive-path-safety|remove-preserves-neighbors|diagnostics|diagnostics-systemd|diagnostics-firewall|diagnostics-forwarding|diagnostics-runtime-diff|diagnostics-read-only|diagnostics-exit-codes|audit|audit-no-secrets|backup-list|restore|restore-invalid|restore-rollback|backup-path-safety|runtime-file-modes|gum-resolution|tui-routing|tui-cancel|tui-read-only|tui-no-gum|tui-non-tty|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 diff --git a/wg-manager.sh b/wg-manager.sh index 39ed2c5..00d3e91 100755 --- a/wg-manager.sh +++ b/wg-manager.sh @@ -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 }