diff --git a/README.md b/README.md index 7199e3b..9f2ccbc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,30 @@ # Forgetting -Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) +Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) > [!WARNING] > Данный проект предназначен только для использования в законных целях. + +- **VPN-пользователи** с ключами `vpn://` для AmneziaVPN и **мастер-ключи** `sen://` для SenAWG: + одна ссылка на несколько устройств, каждое регистрируется само. +- **Несколько серверов** под одной панелью: ноды подключаются к ней сами, панель можно + вынести на отдельный сервер без VPN. +- **Журнал подключений**: кто, когда и сколько был в VPN, кто давно не подключался. +- **Мониторинг**: встроенный Prometheus и графики в панели, метрики для своего Prometheus. +- **Защита от обнаружения**: параметры обфускации, порты и пути у каждого сервера свои, а + открытые порты отвечают сканеру как пустой веб-сервер. + +## Содержание + +- [Архитектура](#архитектура) +- [Требования](#требования) +- [Установка](#установка) · [Параметры установщика](#параметры-установщика) · [Обновление и удаление](#обновление-и-удаление) +- [Несколько серверов](#несколько-серверов) +- [Защита от обнаружения](#защита-от-обнаружения) +- [Журнал подключений](#журнал-подключений) +- [Мониторинг](#мониторинг) +- [Панель за nginx](#панель-за-nginx) +- [API](#api) +- [Управление](#управление) · [Настройки `cli.env`](#настройки-clienv) + ## Архитектура Один статический бинарник `awg-control` (Go) в `/opt/awg-control/` — в нём все роли; @@ -9,17 +32,34 @@ | Компонент | Роль | |---|---| -| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. | -| **awg-ui** (`awg-control ui`) | Веб-панель (фронтенд на React, `awg-ui/dist`), аутентификация (JWT), прокси к awg-ctrl, публичный API `/api/v1` и своя база его ключей. Единственный сервис, доступный по сети. | +| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу (`users.db`) и журнал подключений, генерирует `vpn://` ключи. Слушает только loopback. | +| **awg-ui** (`awg-control ui`) | Веб-панель (React, `awg-ui/dist`), вход по JWT, прокси к awg-ctrl, публичный API `/api/v1`, подписка `sen://`, хаб нод и `/metrics`. База — `ui.db`. | | **awg-agent** (`awg-control agent`) | Только на ноде: исходящее WSS-соединение к панели, исполняет её запросы на локальном awg-ctrl. | -| **cli** (`awg-ctrl …`) | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. | +| **cli** (`awg-ctrl …`) | Менеджер процессов, логин и пароль панели, привязка нод, перенос панели, мониторинг, TUI-меню. | -Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным. +Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только +у awg-ui, awg-ctrl проверяет подпись публичным. Данные — в `/etc/amnezia/amneziawg/` (вне +каталога установки, переустановку переживают), настройки сервисов — в +`/opt/awg-control/cli/cli.env`. + +Порты наружу (номера у каждого сервера случайные): + +| Порт | Что | Кто открывает | +|---|---|---| +| `SERVER_PORT`/udp | VPN-клиенты | везде, где есть VPN | +| `UI_PORT`/tcp | панель и API, под секретным путём | панель | +| `SUB_PORT`/tcp | подписка `sen://` (по умолчанию HTTPS) | панель | +| `NODE_PORT`/tcp | хаб нод (WSS) | панель, если принимает ноды | + +Нода открывает только UDP для клиентов: к панели она подключается сама. ## Требования -- **ОС**: Ubuntu 24.04+ (Тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и переопределяется через `AMNEZIA_PPA_SUITE`) +- **ОС**: Ubuntu 24.04+ (тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка + экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и + переопределяется через `AMNEZIA_PPA_SUITE`). - **Права**: root. -- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG). +- **Виртуализация**: KVM или железо (не OpenVZ/LXC/Docker/WSL — нужен загружаемый + kernel-модуль AmneziaWG). - **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля). - **Архитектура**: x86_64 или aarch64. @@ -34,34 +74,186 @@ bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/instal ``` Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с -`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор -с текстом скрипта. +`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор с текстом скрипта. ```bash sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)" ``` -Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре -старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить -на 2.0 можно так: +Установщик спросит роль сервера: -```bash -AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh) -``` +| Роль | Что ставится | +|---|---| +| **Панель + VPN** (`standalone`) | Всё на одной машине; к ней можно подключать ноды | +| **Только панель** (`core`) | Панель без VPN; серверы подключаются к ней нодами | +| **Нода** (`node`) | VPN без панели; подключается к панели по строке `awgjoin://…` («Добавить сервер» в панели) | -Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели — -переустановка не нужна, пользователи сохраняются, ключи перевыпускаются -автоматически. +В конце установщик печатает адрес панели **вместе с секретным путём** — +`http://IP:PORT/<путь>/` — логин и пароль. Без пути панель отвечает `404`, поэтому адрес +сохраните целиком; позже его покажут `awg-ctrl status` и `awg-ctrl credentials`. -Источник архива и имя панели не зашиты в код — задаются переменными -`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика -(подробности — в комментариях `install.sh` и в `.env`). +Порты на самом сервере установщик открывает сам (UFW, firewalld или iptables — что +активно). **Фаервол хостера** (панель провайдера, security group) отсюда не виден: открой там +порты из итога установки, иначе клиенты не подключатся, а на сервере всё будет выглядеть +исправным. -### Панель за nginx +Сервер ставится на AmneziaWG 3.1. Если модуль в ядре старше 3.x или ядро старше 5.5, +установка остановится с диагностикой — поставить на 2.0 можно через `AWG_GEN=auto`. Перевести +сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели: пользователи сохраняются, +ключи перевыпускаются автоматически. -Панель работает и в корне домена, и в любой подпапке — пути в ней относительные, -настраивать сам префикс не нужно. Пример для `https://example.com/forgetting/` -(`39708` — `PORT` панели из `/opt/awg-control/cli/cli.env`): +### Параметры установщика + +Задаются переменными окружения перед запуском, например +`ROLE=node JOIN='awgjoin://…' bash <(curl -Ls …/install.sh)`. Всё необязательно. + +| Переменная | Что делает | +|---|---| +| `ROLE` | `standalone`, `core` или `node` — без вопроса | +| `JOIN` | строка `awgjoin://…` для ноды | +| `AWG_GEN` | `3.1` (по умолчанию), `auto` — 3.1, если ядро позволяет, иначе 2.0; `2.0` — не трогать модуль | +| `AWG_PIN` | `y` (по умолчанию) — закрепить версию пакетов AmneziaWG (`apt-mark hold`) | +| `AWG_PORT` | UDP-порт VPN вместо случайного | +| `AWG_MTU` | MTU туннеля вместо случайного (1280–1500) | +| `UI_PORT`, `UI_USER`, `UI_PASS` | порт, логин и пароль панели — без вопросов | +| `UI_PREFIX` | свой секретный путь панели (8–64 символа `A–Z a–z 0–9 _ -`); `off` — без пути | +| `SERVER_NAME` | имя VPN-сервера — без вопроса | +| `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов | +| `ACTION` | `update`, `reinstall` или `uninstall` — действие без меню | +| `UNINSTALL` | `y` — удалить без меню и без подтверждения | +| `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) | +| `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `AWG_LOCAL_TARBALL` | откуда брать архив релиза (своё зеркало, локальный файл) | +| `BRAND`, `CHANNEL` | имя продукта в баннерах и панели | + +### Обновление и удаление + +Повторный запуск установщика на сервере с установкой — или пункты **7–9** меню `awg-ctrl`, или +команды `awg-ctrl update | reinstall | uninstall` — предлагает: + +1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. Установке без + секретного пути панели он выдаётся, и установщик печатает новый адрес: закладки и адрес + внешнего API нужно обновить. Оставить без пути — `UI_PREFIX=off` перед запуском или пустое + `UI_PREFIX=` в `cli.env`. +2. **Переустановить** — с нуля; пользователей и ключ сервера можно сохранить. Тогда по умолчанию + остаются и порты (VPN, панели, подписки, хаба), параметры обфускации, MTU, секретный путь, + логин и пароль панели — они зашиты в выданные ключи, ссылки и закладки. Прежняя установка + убирается только после ответа «да» на «Продолжить?». +3. **Удалить** — полностью (без меню: `UNINSTALL=y`, подтверждение «удалить» тогда не + спрашивается). + +Удаление снимает сервисы и юнит systemd, интерфейс `awg1`, nft-таблицы шейпинга и блокировки +торрентов, открытые установщиком порты, Prometheus из `awg-ctrl monitor install`, +`/opt/awg-control`, `/etc/amnezia` (пользователи, ключи, базы), команду `awg-ctrl`, sysctl-файл +и логи установки (в них пароль панели). Перед этим установщик предлагает сохранить копию данных +в `/root/forgetting-backup-<время>.tar.gz`. AmneziaWG (пакеты, модуль, PPA) — по отдельному +вопросу; общие зависимости (dkms, build-essential, iptables, заголовки ядра) остаются. Ноду +после удаления уберите и из списка серверов панели. + +## Несколько серверов + +**Подключить сервер.** В панели «Добавить сервер» выдаёт строку `awgjoin://…` (действует час). +На новом сервере — установщик с ролью «Нода» и этой строкой, или на уже установленном — +`awg-ctrl join ''`. Агент ноды сам подключается к хабу панели по WSS; доверие — +закреплённый отпечаток сертификата хаба и ключ ноды. В панели нода появляется в списке +серверов, ею управляют так же, как своим. `awg-ctrl unjoin` — отвязать. + +**Перенести панель на отдельный сервер** (например, без VPN). «Настройки» → «Перенос» или CLI: + +1. На новом сервере — установка с ролью «Только панель». +2. На новой панели — код переноса `awgmove://…`; на старой — вставить его (или + `awg-ctrl migrate export` на старом и `awg-ctrl migrate import <архив>` на новом). +3. Старый сервер становится нодой #0 новой панели и пересылает туда свои прежние порты: + ссылки `sen://`, ноды и внешний API продолжают работать, закладки панели — тоже (новая + панель принимает и свой секретный путь, и старый). +4. Когда раздел «Перенос» покажет, что новый адрес знают все устройства и ноды, старый + сервер можно выключать. Ноды, переехавшие вручную, — `awg-ctrl rehome <хост[:порт]>`. + +`awg-ctrl migrate cancel` — отменить на старом сервере, пока перенос не завершён. + +## Защита от обнаружения + +Раньше у всех установок были одинаковые порт, параметры обфускации и ответы на запросы — одно +правило DPI находило их все. Теперь общего у серверов нет: + +**VPN.** На новой установке случайны: +- UDP-порт (20000–59999), если не задан `AWG_PORT`; +- параметры обфускации `Jc/Jmin/Jmax`, `S1–S4` (размеры handshake-пакетов разные — по длине + тип пакета не узнать), `H1–H4` (непересекающиеся диапазоны); +- `I1` — пакет-маскировка перед handshake: DNS-запрос к случайному популярному домену (формат + проверяется до записи — его принимают и kernel-модуль, и amneziawg-go); +- MTU в диапазоне 1320–1376. + +При переустановке с сохранением данных всё это берётся из прежнего `awg1.conf`: параметры +зашиты в выданные ключи. Поэтому **серверы, поставленные до рандомизации, сохраняют старые общие +значения** — чтобы уйти от них, нужна переустановка без сохранения данных и раздача новых +ключей. Исключение — `I1`: его сервер не сверяет, и если в конфиге его не было, при переустановке +он генерируется новым, не ломая старые ключи. + +**Открытые порты** отвечают тому, кто не доказал право на ответ, как пустой веб-сервер на Go — +`404 page not found`: + +| Порт | Кому отвечает по протоколу | +|---|---| +| Панель | запросам под секретным путём (`UI_PREFIX`); с loopback — и без него | +| Подписка `sen://` | `peek`/`register` с секретом мастер-ключа из ссылки; остальным запросам — с подписью ключом устройства | +| Хаб нод | агенту, подписавшему своим ключом сам запрос на WebSocket | + +Подробности: +- `GET /ui/brand` (имя и версия) — только после входа; экран логина не показывает имя продукта. +- Удалённому устройству подписка отвечает `410` (снять конфиг), только если запрос подписан его + ключом (`revoked_devices`); устройства, удалённые до этой версии, получают тихий `404`. +- Пока выдан код переноса, `POST /migrate/v1/import` принимается и без секретного пути. +- Агенты нод прежних версий подписи не шлют: хаб пускает их, пока в панели есть нода, ни разу + не подключавшаяся по-новому, и пишет об этом в лог. Обновили все ноды — путь без подписи + закрывается сам (`HUB_LEGACY` в `cli.env` — принудительно). Часы ноды могут расходиться с + панелью до суток. +- Самоподписанные сертификаты подписки и хаба получают случайное имя вместо общих `sen` / + `awg-node`. Клиенты и ноды сверяют ключ, а не имя; прежние сертификаты остаются со старым именем. + +**Чего это не закрывает.** Секретный путь и тихий `404` защищают от активного сканирования. +Панель по умолчанию работает по голому HTTP — её трафик (пути, JSON, токены) виден в сети. +Ставьте её за nginx с TLS ([ниже](#панель-за-nginx)) или заходите по VPN. Подписка `sen://` +по умолчанию HTTPS, хаб — всегда TLS. + +## Журнал подключений + +Вкладка «Подключения» показывает по выбранному серверу: + +- **События** — кто подключился к VPN и отключился, когда, сколько длилась сессия и сколько + скачал и отдал. Поиск — по имени пользователя, мастер-ключу, устройству или внутреннему IP. +- **Кто когда был** — все пользователи и устройства `sen://` сервера: онлайн сейчас, когда + были на связи последний раз и кто не подключался ни разу с начала журнала. + +Журнал ведёт awg-ctrl каждого сервера (`conn_log` в `users.db`, 90 дней). У WireGuard нет событий +подключения, поэтому раз в 30 с он сверяет время handshake и счётчики трафика: «подключился» — +время первого handshake, «отключился» — последняя активность (точность около 30 с). Перезапуск +awg-ctrl сессии не рвёт, падение интерфейса отключением не считается. Внешний IP клиентов не +записывается. Серверы на прежней версии журнала не ведут — вкладка попросит их обновить. + +## Мониторинг + +**Встроенный Prometheus.** «Настройки» → «Prometheus» → «Установить»: панель просит awg-ctrl +поставить его (`awg-ctrl monitor install`, только Debian/Ubuntu). Он слушает только +`127.0.0.1:9090`, хранит 31 день и забирает метрики **всех серверов** панели. Графики — скорость, +устройства онлайн, нагрузка, CPU и память — появляются через минуту-две. Удалить — там же или +`awg-ctrl monitor remove`. Графики — во вкладке «Монитор». + +У панели без своего VPN Prometheus ставится на ноду (выберите её в шапке панели). Он +забирает метрики у агента на `127.0.0.1`, а агент — у панели внутри TLS-соединения с хабом; +панель читает графики через того же агента. Снаружи этот трафик не отличить от обычной связи +ноды с панелью, секретный путь ему не нужен. Агенты старше 0.4.6 этого не умеют. + +**Свой Prometheus.** «Настройки» → «Prometheus» → «Токен метрик»: выдать токен «только метрики» +(он не открывает ни панель, ни API) и скопировать готовый фрагмент `prometheus.yml`. Адрес в нём +берётся от страницы панели, поэтому уже содержит секретный путь +(`metrics_path: /<путь>/metrics`) или подпапку nginx. По голому HTTP токен и метрики идут +открытым текстом — скрейпьте через nginx с TLS или по VPN. Метрики по отдельным пирам +выключены (`METRICS_PEERS=on` в `cli.env` — включить). + +## Панель за nginx + +Панель работает и в корне домена, и в любой подпапке — пути в ней относительные. Пример для +`https://example.com/forgetting/` (`39708` — `UI_PORT` из `/opt/awg-control/cli/cli.env`): ```nginx location = /forgetting { return 301 /forgetting/; } @@ -74,28 +266,28 @@ location ^~ /forgetting/ { } ``` -Адрес клиента панель берёт из `X-Forwarded-For` / `X-Real-IP` только от доверенного -прокси: с этой же машины (loopback) — всегда, с других адресов — если они перечислены -в `TRUST_PROXY` в `cli.env` (IP или сети через запятую, например -`TRUST_PROXY=10.0.0.5,192.168.1.0/24`), после чего `awg-ctrl restart`. От остальных -заголовки игнорируются. Без этого все входы шли бы с адреса прокси и делили один -лимит неудачных попыток. +nginx на этой же машине ходит к панели по loopback, поэтому секретный путь ему не нужен — +подпапка в nginx и становится путём панели (выберите неочевидную). Адрес клиента панель берёт +из `X-Forwarded-For` / `X-Real-IP` только от доверенного прокси: с loopback — всегда, с других +адресов — если они перечислены в `TRUST_PROXY`. Без этого все входы шли бы с адреса прокси и +делили один лимит неудачных попыток. -Сам порт панели после этого можно закрыть снаружи (`ufw deny /tcp`) — порты -подписки (`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты -и ноды ходят на них напрямую, не через nginx. +Сам порт панели после этого можно закрыть снаружи (`ufw deny /tcp`). Порты подписки +(`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты и ноды ходят на них +напрямую, не через nginx. ## API -Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — -стабильный контракт для сторонних программ, авторизация по API-ключу. -**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый -URL в обоих случаях — адрес сервера и порт, выбранный при установке. +Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — стабильный контракт +для сторонних программ, авторизация по API-ключу. **Роуты панели** — всё, что умеет +веб-интерфейс, авторизация по JWT. + +В примерах `HOST:PORT` — адрес панели **вместе с секретным путём**: +`http://203.0.113.9:39708/k3f9xQ2mTq8vLw0a`. Без пути снаружи любой запрос получает `404`. > [!WARNING] -> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не -> публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет -> без TLS и ограничения по адресам. +> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не публичный контракт, +> в отличие от `/api/v1`. По голому HTTP они идут открытым текстом: ставьте панель за TLS. ### Публичный API (`/api/v1`) @@ -117,22 +309,20 @@ curl -s -X POST http://HOST:PORT/api/v1/users \ | `GET /api/v1/users/:name` | `{ name, ip, gen, vpn_key }`; `404` — не найден | | `DELETE /api/v1/users/:name` | `{ "success": true, "name": "alice" }` | -`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или -`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить» -ключ = удалить и создать заново. +`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или `"3.1"`). Операции +только CRD: у пира нет изменяемых полей, «перевыпустить» ключ = удалить и создать заново. -У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; -`0` — сервер самой панели, остальные — подключённые ноды). Пользователи `vpn://` -заводятся на сервере ключа по умолчанию; другой сервер из набора выбирается -заголовком `X-Server-Id: `, сервер вне набора → `404`. +У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; `0` — сервер +самой панели, остальные — подключённые ноды). Пользователи `vpn://` заводятся на сервере ключа +по умолчанию; другой сервер из набора выбирается заголовком `X-Server-Id: `, сервер вне +набора → `404`. #### Мастер-ключи `sen://` -Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое -устройство само регистрируется по ссылке и получает пира на всех серверах ключа, -его приватный ключ сервер не видит. API-ключ видит и меняет мастер-ключ, только -если **все** его серверы входят в набор API-ключа; остальные для него не -существуют (`404`). +Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое устройство +само регистрируется по ссылке и получает пира на всех серверах ключа, его приватный ключ сервер +не видит. API-ключ видит и меняет мастер-ключ, только если **все** его серверы входят в набор +API-ключа; остальные для него не существуют (`404`). ```bash curl -s -X POST http://HOST:PORT/api/v1/masterkeys \ @@ -160,12 +350,11 @@ curl -s -X POST http://HOST:PORT/api/v1/masterkeys \ | `401 {"error":"Invalid API key"}` | ключ не найден — удалён или неверен | | `502 {"error":"awg-ctrl недоступен"}` | awg-ui посредничает, а awg-ctrl не отвечает | -Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне -каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про -внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией. -Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем -`servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не -перевыпуская ключ, и `DELETE /ui/apikeys/:id`. Подробно — `awg-ui/API.md`. +Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`), поэтому переустановка их +не трогает. Сама awg-ctrl про внешние ключи ничего не знает: awg-ui ходит к ней своей +внутренней авторизацией. Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` +(с полем `servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не +перевыпуская ключ, и `DELETE /ui/apikeys/:id`. ### Авторизация панели @@ -180,8 +369,9 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \ | `POST /login` | `{ "token": "" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут | | `POST /logout` | `{ "ok": true }`, токен отзывается немедленно | -Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые -дёргает сама панель; в отличие от `/api/v1` их форма может меняться. +Дальше в каждый запрос: `Authorization: Bearer $TOKEN`; сервер — заголовком `X-Server-Id` +(без него — сервер по умолчанию). Ниже — роуты, которые дёргает сама панель; в отличие от +`/api/v1` их форма может меняться. ### Состояние @@ -191,8 +381,8 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \ | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | -`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс: -`"2"` или `"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно. +`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс: `"2"` или +`"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно. ### Пользователи @@ -212,8 +402,16 @@ curl -s -X POST http://HOST:PORT/api/users \ ``` Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`, -base64url-декодировать, отбросить первые 4 байта (длина), распаковать -`zlib inflate` → JSON; текст конфига лежит в `last_config.config`. +base64url-декодировать, отбросить первые 4 байта (длина), распаковать `zlib inflate` → JSON; +текст конфига лежит в `last_config.config`. + +### Журнал подключений + +| Метод | Ответ | +|---|---| +| `GET /ui/connlog` | Журнал сервера из `X-Server-Id`: `{ events: [{ id, at, event, name, owner, ip, key?, device?, duration?, rx?, tx? }], peers: [{ name, owner, ip, online, last_seen, key?, device? }], since, keep_days }`. `event` — `connect` или `disconnect` (у него `duration`, `rx`, `tx` — со стороны сервера); `key`/`device` — мастер-ключ и устройство `sen://`. Параметры: `limit` (до 500), `before=` — следующая страница, `q` — поиск по имени, владельцу, IP. `501` — сервер старой версии | + +Без имён `sen://` тот же журнал отдаёт awg-ctrl: `GET /api/connlog`. ### Интерфейс AWG @@ -223,10 +421,9 @@ base64url-декодировать, отбросить первые 4 байта | `POST /awg/restart` | `awg-quick down/up` + ресинк пиров, `{ "success": true }`. Соединения клиентов кратковременно рвутся | | `POST /awg/upgrade` | Переводит сервер с 2.0 на 3.1: правит `awg1.conf`, перезапускает интерфейс и перевыпускает все ключи. `{ gen, backup, bumpedS: [], reissue: { total, reissued, … } }` | -`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не -3.x или ядро старше 5.5, и `500` с текстом причины, если ядро не приняло -3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс -поднимается обратно на 2.0. +`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не 3.x или ядро +старше 5.5, и `500` с текстом причины, если ядро не приняло 3.1-конфиг — в этом случае прежний +конфиг возвращается из копии, а интерфейс поднимается обратно на 2.0. ### Мастер-ключи @@ -245,24 +442,66 @@ base64url-декодировать, отбросить первые 4 байта | `DELETE /ui/devices/:id` | Отвязать устройство | | `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства | +### Метрики + +| Метод | Что делает | +|---|---| +| `GET /ui/metrics-token` | `{ exists, prefix?, created_at?, last_used?, per_peer }` — токен для своего Prometheus | +| `POST /ui/metrics-token` | Выпустить или перевыпустить: `201` с `token` (показывается один раз); старый перестаёт работать | +| `DELETE /ui/metrics-token` | Отозвать | +| `GET /metrics` | Экспозиция Prometheus по всем серверам; `Authorization: Bearer awgm_…` | + ## Управление ```bash awg-ctrl # меню -awg-ctrl start|stop|restart|status -awg-ctrl credentials [user|pass] +awg-ctrl start|stop|restart|status # status показывает и адрес панели с путём +awg-ctrl credentials [user|pass] # логин и пароль панели, адрес awg-ctrl join '' [--no-panel] / unjoin awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес -awg-ctrl migrate export [файл] # перенос панели на отдельный сервер без VPN -awg-ctrl migrate import <архив> # (порядок — docs/node-protocol.md, «Перенос панели») +awg-ctrl peers [drop <область>] # пиры sen:// на этом сервере по панелям +awg-ctrl monitor install|status|remove # встроенный Prometheus +awg-ctrl migrate export [файл] # перенос панели (см. «Несколько серверов») +awg-ctrl migrate import <архив> awg-ctrl migrate cancel +awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh) +awg-ctrl panel [path new|off|<путь>] # адрес панели; новый, свой или без секретного пути +awg-ctrl log [<поиск>] [-n N] # журнал VPN-подключений этого сервера +awg-ctrl log seen # кто когда был: онлайн, последний раз, не подключался +awg-ctrl metrics [token|revoke] # токен и prometheus.yml для своего Prometheus +awg-ctrl hub [legacy on|off|auto] # ноды хаба: кто с подписью, кто ещё старый +awg-ctrl check # что на сервере выдаёт его сканеру и DPI ``` +В меню `awg-ctrl` те же возможности — пункты 10–13: адрес и секретный путь панели, журнал, +«кто когда был» и проверка скрытности. + +`update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё +зеркало), `uninstall` берёт его копию из установки и работает без сети. + Через systemd (автозапуск на загрузке): ```bash systemctl start|stop|restart awg-control ``` +### Настройки `cli.env` + +`/opt/awg-control/cli/cli.env` пишет установщик; после правки — `awg-ctrl restart`. Чаще всего +нужны: + +| Переменная | Что делает | +|---|---| +| `UI_PORT`, `UI_USER`, `UI_PASS` | порт, логин и пароль панели (`awg-ctrl credentials`) | +| `UI_PREFIX` | секретный путь панели; пусто — без пути | +| `UI_PREFIX_MOVED` | путь панели, переехавшей сюда: принимается вместе со своим | +| `SUB_PORT`, `SUB_TLS`, `SUB_HOST` | подписка `sen://`: порт, HTTPS, адрес в ссылках | +| `NODE_PORT` | хаб нод; пусто — ноды не принимаются | +| `HUB_LEGACY` | `on` / `off` — пускать ли агентов нод без подписи; пусто — пока не обновлены все | +| `TRUST_PROXY` | IP или сети reverse-proxy, кроме loopback: от них верим `X-Forwarded-For` | +| `METRICS_PEERS` | `on` — метрики по отдельным пирам | +| `AUDIT` | `off` — выключить периодический аудит пиров | +| `INSTALLER_URL` | откуда `awg-ctrl update`/`reinstall` берут установщик (своё зеркало) | + ## Лицензия [MIT](LICENSE) © 2026 Ivan Vasilev diff --git a/awg-ui/index.html b/awg-ui/index.html index 53a3a4a..0fea124 100644 --- a/awg-ui/index.html +++ b/awg-ui/index.html @@ -3,7 +3,7 @@ - AWG Panel + Вход
diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index f29c8d1..9bbc757 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -326,6 +326,30 @@ app.post("/api/masking", (q, r) => { r.json(I1_SAMPLES[maskingOf[id]]); }); +// Журнал подключений (вкладка «Подключения»): несколько событий за двое суток. +app.get("/ui/connlog", (q, r) => { + if (q.query.before) return r.json({ events: [], peers: [], since: null, keep_days: 30 }); + const now = Math.floor(Date.now() / 1000); + const who = [ + { pub_key: "aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9d=", name: "alice", owner: "", ip: "10.9.0.2" }, + { pub_key: "Zx8Cv6Bn4Mq2We0Rt8Yu6Io4Pa2Sd0Fg8Hj6Kl4Zx2C=", name: "", owner: "mk1", ip: "10.9.0.7", key: "Семья", device: "Pixel 7 Pro" }, + { pub_key: "Qw1Er3Ty5Ui7Op9As1Df3Gh5Jk7Lz9Xc1Vb3Nm5Qw7E=", name: "bob", owner: "", ip: "10.9.0.3" }, + ]; + const events = [ + { ...who[0], id: 6, at: now - 300, event: "connect" }, + { ...who[1], id: 5, at: now - 3900, event: "disconnect", duration: 5400, rx: 812e6, tx: 64e6 }, + { ...who[2], id: 4, at: now - 9000, event: "disconnect", duration: 640, rx: 12e6, tx: 2e6 }, + { ...who[2], id: 3, at: now - 9640, event: "connect" }, + { ...who[1], id: 2, at: now - 9300, event: "connect" }, + { ...who[0], id: 1, at: now - 90000, event: "disconnect", duration: 7200, rx: 2.1e9, tx: 190e6 }, + ]; + const peers = [ + { ...who[0], online: true, last_seen: now - 20 }, + { ...who[1], online: false, last_seen: now - 3900 }, + { ...who[2], online: false, last_seen: now - 9000 }, + ]; + r.json({ events, peers, since: now - 3 * 86400, keep_days: 30 }); +}); app.get("/api/users", (q, r) => r.json({ users: U_(q) })); // Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый». // С меткой «торренты» — тоже фоновый (пока включено деление канала). diff --git a/awg-ui/server.ts b/awg-ui/server.ts index ed2fc4a..0eb3a22 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -206,9 +206,9 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => { res.json({ ok: true }); }); -// Единственный роут без авторизации: вордмарк нужен уже на экране логина, -// до выдачи JWT. Наружу уходит только имя продукта и версия. -app.get("/ui/brand", (_req: Request, res: Response) => { +// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting. +// Экран логина обходится без вордмарка. +app.get("/ui/brand", requireAuth, (_req: Request, res: Response) => { res.json({ brand: BRAND, channel: CHANNEL, version: VERSION }); }); diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index 0e21de4..645be6e 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -190,17 +190,20 @@ export default function App() { return () => clearInterval(t); }, [token, loadNodes]); - // Имя продукта и версия — с сервера, до логина (роут без авторизации). - // Если не ответил, остаётся фолбэк из констант. + // Имя продукта и версия — с сервера, только после логина: роут за авторизацией, чтобы + // панель не опознавалась сканером. Если не ответил, остаётся фолбэк из констант. useEffect(() => { - axios.get('/ui/brand') + if (!token) return; + axios.get('/ui/brand', { headers: { Authorization: `Bearer ${token}` } }) .then(({ data }) => setBrand({ brand: data.brand || BRAND_FALLBACK, channel: data.channel || '', version: data.version || '', })) .catch(() => { /* остаётся фолбэк */ }); - }, []); + }, [token]); + + useEffect(() => { document.title = token ? brand.brand : 'Вход'; }, [token, brand.brand]); // Блокировать прокрутку фона когда drawer открыт useEffect(() => { @@ -308,7 +311,7 @@ export default function App() {
{themeToggle}
- {brand.brand} + Вход

Войдите, чтобы продолжить

( ); +export const IcoHistory = () => ( + + + +); + export const ICONS: Record JSX.Element> = { settings: IcoGear, users: IcoUsers, @@ -139,4 +145,5 @@ export const ICONS: Record JSX.Element> = { shield: IcoShield, pulse: IcoPulse, move: IcoMove, + history: IcoHistory, }; \ No newline at end of file diff --git a/awg-ui/src/tabs/connlog/connlog.css b/awg-ui/src/tabs/connlog/connlog.css new file mode 100644 index 0000000..77ae4b6 --- /dev/null +++ b/awg-ui/src/tabs/connlog/connlog.css @@ -0,0 +1,36 @@ +/* Вкладка «Подключения»: лента событий и список «кто когда был». Строки — гибкие, без таблицы: + на телефоне колонки сами переносятся. */ +.cl { display: flex; flex-direction: column; gap: 12px; } + +.cl-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.cl-search { flex: 1; min-width: 200px; width: auto; } + +.cl-hint { margin: 0; font-size: 13px; color: var(--neutral-text); } +.cl-err { text-align: left; } + +.cl-list { + list-style: none; margin: 0; padding: 0; + background: var(--surface-bright); + border: 1px solid var(--outline-variant); border-radius: var(--shape-large); + overflow: hidden; +} +.cl-row { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; + padding: 12px 16px; + border-top: 1px solid var(--outline-variant); +} +.cl-row:first-child { border-top: none; } +.cl-time { flex: 0 0 110px; font-size: 13px; color: var(--neutral-text); font-variant-numeric: tabular-nums; } +.cl-chip { flex: none; min-width: 112px; } +.cl-who { flex: 1 1 200px; display: flex; flex-direction: column; min-width: 0; } +.cl-who b { font-weight: 500; overflow-wrap: anywhere; } +.cl-sub { font-size: 12px; color: var(--neutral-text); overflow-wrap: anywhere; } +.cl-session, .cl-seen { font-size: 13px; color: var(--on-surface); font-variant-numeric: tabular-nums; } +.cl-empty { padding: 24px 16px; text-align: center; color: var(--neutral-text); } +.cl-more { display: flex; justify-content: center; padding: 12px; border-top: 1px solid var(--outline-variant); } + +@media (max-width: 640px) { + .cl-time { flex-basis: auto; } + .cl-views { width: 100%; } + .cl-views .segmented-item { flex: 1; } +} diff --git a/awg-ui/src/tabs/connlog/index.tsx b/awg-ui/src/tabs/connlog/index.tsx new file mode 100644 index 0000000..4738a53 --- /dev/null +++ b/awg-ui/src/tabs/connlog/index.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { apiFetch, bytes, timeAgo, type PageProps } from '../../lib/shared'; +import { IcoCheck, IcoRefresh } from '../../components/icons'; +import './connlog.css'; + +// Вкладка «Подключения»: журнал VPN выбранного сервера. Ведёт его awg-ctrl (internal/ctrl/connlog.go) +// по handshake и счётчикам WireGuard раз в 30 с, панель подставляет имена ключей и устройств sen:// +// (/ui/connlog). Время отключения — последняя активность, точность около 30 с. +interface Who { pub_key: string; name: string; owner: string; ip: string; key?: string | null; device?: string | null; foreign?: boolean } +interface Ev extends Who { id: number; at: number; event: 'connect' | 'disconnect'; duration?: number | null; rx?: number | null; tx?: number | null } +interface Peer extends Who { online: boolean; last_seen: number | null } +interface Log { events: Ev[]; peers: Peer[]; since: number | null; keep_days: number } + +const errText = (e: any): string => e?.response?.data?.error ?? 'Не удалось получить журнал'; + +// Кто это: пользователь — по имени, устройство sen:// — ключ и устройство. +function who(w: Who): { title: string; sub: string } { + if (w.name) return { title: w.name, sub: 'пользователь' }; + if (w.foreign) return { title: 'Устройство другой панели', sub: w.owner }; + if (w.owner) { + return { title: w.device || 'Удалённое устройство', sub: w.key ? `мастер-ключ «${w.key}»` : 'мастер-ключ удалён' }; + } + return { title: `ключ ${w.pub_key.slice(0, 10)}…`, sub: 'не из панели' }; +} + +const pad = (n: number) => String(n).padStart(2, '0'); +function when(ts: number): string { + const d = new Date(ts * 1000), now = new Date(); + const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + if (d.toDateString() === now.toDateString()) return `сегодня ${time}`; + const y = new Date(now); y.setDate(now.getDate() - 1); + if (d.toDateString() === y.toDateString()) return `вчера ${time}`; + return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}${d.getFullYear() === now.getFullYear() ? '' : '.' + d.getFullYear()} ${time}`; +} +function duration(s: number): string { + if (s < 60) return `${s} сек`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} мин`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} ч ${m % 60} мин`; + return `${Math.floor(h / 24)} дн ${h % 24} ч`; +} + +export default function ConnLogPage({ token, showMsg }: PageProps) { + const [view, setView] = useState<'events' | 'peers'>('events'); + const [log, setLog] = useState(null); + const [more, setMore] = useState([]); // подгруженные страницы старее первой + const [done, setDone] = useState(false); // старее событий нет + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + const [query, setQuery] = useState(''); + + // Первая страница: обновляем раз в 30 с — журнал пишется с тем же шагом. + const load = useCallback(async () => { + try { + const l = await apiFetch('GET', '/ui/connlog?limit=200', token) as Log; + setLog(l); setErr(''); + if (l.events.length < 200) setDone(true); + } catch (e) { setErr(errText(e)); } + }, [token]); + useEffect(() => { + void load(); + const t = setInterval(() => { void load(); }, 30000); + return () => clearInterval(t); + }, [load]); + + const all = useMemo(() => { + const first = log?.events ?? []; + const seen = new Set(first.map(e => e.id)); + return [...first, ...more.filter(e => !seen.has(e.id))]; + }, [log, more]); + + const loadMore = async () => { + const last = all[all.length - 1]; + if (!last) return; + setBusy(true); + try { + const l = await apiFetch('GET', `/ui/connlog?limit=200&before=${last.id}`, token) as Log; + setMore(m => [...m, ...l.events]); + if (l.events.length < 200) setDone(true); + } catch (e) { showMsg(errText(e)); } + finally { setBusy(false); } + }; + + const q = query.trim().toLowerCase(); + const match = (w: Who) => !q || [w.name, w.owner, w.ip, w.key ?? '', w.device ?? '', w.pub_key] + .some(s => s.toLowerCase().includes(q)); + + const events = all.filter(match); + const peers = (log?.peers ?? []).filter(match).sort((a, b) => + Number(b.online) - Number(a.online) || (b.last_seen ?? 0) - (a.last_seen ?? 0)); + const onlineNow = (log?.peers ?? []).filter(p => p.online).length; + + return ( +
+
+
+ + +
+ setQuery(e.target.value)} /> + +
+ + {err &&

{err}

} + {!log && !err &&

Загрузка…

} + + {log && ( +

+ Сейчас онлайн: {onlineNow} из {log.peers.length}. + {' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней. : 'Журнал пуст: события появятся при первом подключении.'} + {' '}Время отключения — последняя активность, точность около 30 секунд. +

+ )} + + {log && view === 'events' && ( +
    + {events.length === 0 &&
  • {q ? 'Ничего не найдено' : 'Событий пока нет'}
  • } + {events.map(e => { + const w = who(e); + const on = e.event === 'connect'; + return ( +
  • + {when(e.at)} + + {on ? 'подключился' : 'отключился'} + + + {w.title} + {w.sub}{e.ip && <> · {e.ip}} + + {!on && ( + + {e.duration != null && <>сессия {duration(e.duration)}} + {/* rx/tx — со стороны сервера: то, что сервер принял, клиент отправил */} + {(e.tx || e.rx) ? <> · ↓ {bytes(e.tx ?? 0)} ↑ {bytes(e.rx ?? 0)} : null} + + )} +
  • + ); + })} + {!done && !q && all.length > 0 && ( +
  • + +
  • + )} +
+ )} + + {log && view === 'peers' && ( +
    + {peers.length === 0 &&
  • {q ? 'Ничего не найдено' : 'На сервере нет пользователей'}
  • } + {peers.map(p => { + const w = who(p); + return ( +
  • + + {w.title} + {w.sub}{p.ip && <> · {p.ip}} + + {p.online ? ( + онлайн + ) : p.last_seen ? ( + был {when(p.last_seen)} ({timeAgo(p.last_seen)} назад) + ) : ( + не подключался{log.since ? ` с ${when(log.since)}` : ''} + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/awg-ui/src/tabs/connlog/metadata.json b/awg-ui/src/tabs/connlog/metadata.json new file mode 100644 index 0000000..cf7bad5 --- /dev/null +++ b/awg-ui/src/tabs/connlog/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "connlog", + "label": "Подключения", + "icon": "history", + "group": "main", + "order": 1.5 +} diff --git a/awg-ui/src/tabs/settings/index.tsx b/awg-ui/src/tabs/settings/index.tsx index 759c3e6..0c02468 100644 --- a/awg-ui/src/tabs/settings/index.tsx +++ b/awg-ui/src/tabs/settings/index.tsx @@ -53,6 +53,22 @@ function savedSection(): SectionId { return 'look'; } +// Фрагмент prometheus.yml для внешнего Prometheus. Адрес — от страницы: в нём уже секретный путь +// панели (UI_PREFIX) или подпапка nginx, поэтому конфиг верен в обоих случаях. +function scrapeConfig(tok: string): string { + const u = new URL('metrics', location.href.split(/[?#]/)[0]); + return [ + 'scrape_configs:', + ' - job_name: vpn-panel', + ` scheme: ${u.protocol.replace(':', '')}`, + ` metrics_path: ${u.pathname}`, + ' authorization:', + ` credentials: ${tok}`, + ' static_configs:', + ` - targets: ['${u.host}']`, + ].join('\n'); +} + const THEMES: { id: ThemeMode; label: string }[] = [ { id: 'light', label: 'Светлая' }, { id: 'dark', label: 'Тёмная' }, @@ -266,16 +282,19 @@ export default function SettingsPage({ token, showMsg }: PageProps) {
e.stopPropagation()}>

Токен метрик выпущен

- Скопируй его сейчас — он показывается один раз. В prometheus.yml он идёт - в authorization.credentials. + Скопируй его сейчас — он показывается один раз. Готовый фрагмент для prometheus.yml:

- {created} +
{scrapeConfig(created)}
- + }}> Токен +
diff --git a/awg-ui/src/tabs/settings/settings.css b/awg-ui/src/tabs/settings/settings.css index 6e1fdc0..1b89d8a 100644 --- a/awg-ui/src/tabs/settings/settings.css +++ b/awg-ui/src/tabs/settings/settings.css @@ -117,3 +117,6 @@ LICENSE file in the root directory of this source tree. .st-row-ctl { width: 100%; } .st-row-ctl .btn { flex: 1; } } + +/* Фрагмент prometheus.yml в диалоге токена: переносы строк сохраняются */ +.st-yml { white-space: pre-wrap; margin: 0; text-align: left; } diff --git a/install.sh b/install.sh index e61e8c1..94f014e 100644 --- a/install.sh +++ b/install.sh @@ -1,10 +1,24 @@ +#!/usr/bin/env bash # Copyright (c) 2026 Ivan Vasilev # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -#!/usr/bin/env bash +# +# Установщик Forgetting: панель управления VPN на AmneziaWG. +# bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh) +# Поверх установки — меню: обновить, переустановить, удалить. Параметры (ROLE, JOIN, AWG_GEN, +# AWG_PORT, AWG_MTU, UI_PREFIX, UNINSTALL…) задаются окружением — таблица в README, +# «Параметры установщика». +# +# Порядок файла: помощники вывода → константы → функции → сценарий (с «Сценарий» ниже). set -euo pipefail +# Запущен из меню awg-ctrl — с временной копии (internal/cli/installer.go): убираем её сразу, +# bash дочитает скрипт из уже открытого файла. +case "${BASH_SOURCE[0]:-}" in + */awg-install-[0-9]*.sh) rm -f -- "${BASH_SOURCE[0]}" ;; +esac + # Цвета — настоящие ESC-символы ($'…'), а не '\033' строкой: так они работают и в echo -e, # и в приглашениях read -p. ACC — фиолетовый акцент панели (design.md). Без терминала # (перенаправили в файл) или с NO_COLOR — без цветов. @@ -109,6 +123,9 @@ rand_alnum() { head -c 1024 /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' | head -c "$1" } +rand_int() { shuf -i "$1-$2" -n 1; } +url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; } + # Вопрос: ask VAR "текст: " [secret]. Введённое печатает сам терминал — в поток вывода, а значит # и в лог, оно не попадает. Поэтому после ответа строка «вопрос ответ» печатается ещё раз: на # экране — поверх себя же (курсор на строку вверх), в логе — целиком. Скрытый ввод — точками. @@ -131,6 +148,16 @@ ask() { printf -v "$__var" '%s' "$__ans" } +# ask_def VAR "вопрос" ПО_УМОЛЧАНИЮ [secret] — спросить, только если VAR не задана окружением; +# пустой ответ — значение по умолчанию. +ask_def() { + local __v="$1" + [[ -n "${!__v:-}" ]] && return 0 + ask "$__v" "$2" "${4:-}" + [[ -n "${!__v}" ]] || printf -v "$__v" '%s' "$3" +} + +# shellcheck disable=SC2154 # rc присваивается в самой строке trap trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} строка ${LINENO} код ${rc}\n команда: ${BASH_COMMAND}\n полный лог: ${LOGFILE:-<ещё не открыт>}" >&2' ERR [[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh" @@ -157,6 +184,7 @@ LOG_PID="${!:-}" trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true; exec 3>&- for _ in {1..20}; do kill -0 "$LOG_SED_PID" 2>/dev/null || break; sleep 0.1; done' EXIT +# ═══ Константы ═════════════════════════════════════════════════════════════════ # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # При бампе версии правь оба места. @@ -184,7 +212,6 @@ case "$(uname -m)" in esac ARCHIVE_NAME="awgcontrol-${VERSION}-linux-${GOARCH:-unknown}.tar.gz" ARCHIVE_URL="${ARCHIVE_URL:-}" -url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; } PROJECT="/opt/awg-control" AMNEZIA_DIR="/etc/amnezia" AWG_DIR="$AMNEZIA_DIR/amneziawg" @@ -202,7 +229,7 @@ INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key" SUB_SIGN_PRIV="$AWG_DIR/sub_sign.key" SUB_TLS_KEY="$AWG_DIR/sub_tls.key" SUB_TLS_CRT="$AWG_DIR/sub_tls.crt" -# Несколько серверов (docs/node-protocol.md). Хаб панели: самоподписанный сертификат, его +# Несколько серверов (README, «Несколько серверов»). Хаб панели: самоподписанный сертификат, его # отпечаток зашит в строки подключения нод — живёт столько же, сколько панель. Нода: свой # Ed25519-ключ, которым она представляется панели. NODE_TLS_KEY="$AWG_DIR/node_tls.key" @@ -211,333 +238,12 @@ NODE_KEY_FILE="$AWG_DIR/node.key" IFACE="awg1" AWG_PORT="${AWG_PORT:-}" # UDP для клиентов; пусто — выбирает choose_awg_params SUBNET="10.9" -MTU="1376" +MTU="${AWG_MTU:-}" # MTU туннеля; пусто — выбирает choose_awg_params # Единый бинарник: сервисы (ctrl/ui/agent) и CLI (start/stop/status/…). Команда awg-ctrl — # ссылка на него. BIN="$PROJECT/awg-control" -echo -card_line "$ACC" -card_line "$ACC" "${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" -card_line "$ACC" "${DIM}Панель управления VPN на AmneziaWG — установка${NC}" -card_line "$ACC" -card_line "$ACC" "${DIM}лог $LOGFILE${NC}" -card_line "$ACC" - -# Поверх существующей установки: обновить (настройки, пользователи и роль остаются) -# или переустановить с нуля. Спрашиваем первым — от ответа зависит, спрашивать ли роль. -# -# Установку старше MIN_UPGRADE_VERSION не обновить и не перенести: с ней несовместимы и данные, -# и выданные ключи. Для неё — только полная переустановка со сбросом всех данных (WIPE=y): -# пользователи, мастер-ключи, ключи API, ключи сервера и ноды — всё заново. Старые базы уходят -# в бэкап (*.bak-…), как при обычной переустановке, но перенести их нельзя. Версия — из .env -# прежней установки (он есть с 0.2.0); нет его — версия неизвестна и тоже считается устаревшей. -MIN_UPGRADE_VERSION="0.5.0" -version_lt() { [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]]; } -INSTALL_MODE="fresh" -WIPE="n" -PREV_VERSION="" -if [[ -d "$PROJECT" ]]; then - PREV_VERSION=$(sed -n 's/^VERSION=//p' "$PROJECT/.env" 2>/dev/null | head -1 | tr -d "\"' " || true) - echo - if [[ -z "$PREV_VERSION" ]] || version_lt "${PREV_VERSION%%-*}" "$MIN_UPGRADE_VERSION"; then - card_line "$RED" - card_line "$RED" "${RED}${BLD}✗ Установлена устаревшая версия ${PREV_VERSION:-(неизвестна)}${NC}" - card_line "$RED" - card_line "$RED" "Обновить её до ${VERSION} нельзя: версии старше ${MIN_UPGRADE_VERSION} нужна полная переустановка." - card_line "$RED" "Будут удалены все ключи и данные:" - card_line "$RED" " ${DIM}·${NC} пользователи и их vpn:// ключи" - card_line "$RED" " ${DIM}·${NC} мастер-ключи и ссылки sen://, ключи API" - card_line "$RED" " ${DIM}·${NC} ключи сервера и подключение к панели (ноду придётся добавить заново)" - card_line "$RED" - card_line "$RED" "Выданные ключи перестанут работать — клиентам нужно будет выдать новые." - card_line "$RED" "${DIM}Старые базы останутся в бэкапе (*.bak-…), но перенести их в ${VERSION} нельзя.${NC}" - card_line "$RED" - echo - ask WIPE_YN "Переустановить с удалением всех ключей? [y/N]: " - [[ "${WIPE_YN:-n}" =~ ^[Yy]$ ]] || { echo " Отменено — ничего не изменено."; exit 0; } - WIPE="y" - else - warn "Уже установлено в $PROJECT ${DIM}(версия ${PREV_VERSION})${NC}" - echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" - echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" - ask INST_CHOICE "Выбери [1/2, Enter — 1]: " - case "${INST_CHOICE:-1}" in - 1) INSTALL_MODE="update" ;; - 2) INSTALL_MODE="fresh" ;; - *) fail "Неверный выбор: введи 1 или 2" ;; - esac - fi - echo -fi - -# cli.env прежней установки читаем сразу: при переустановке каталог проекта удаляется -# раньше, чем до него доходит дело, а из него берутся роль и порты, зашитые в уже -# выданные ссылки (SUB_PORT, NODE_PORT) и в подключение ноды к панели. -PREV_CLI_ENV="" -[[ -f "$PROJECT/cli/cli.env" ]] && PREV_CLI_ENV=$(cat "$PROJECT/cli/cli.env") -prev_val() { grep "^$1=" <<< "$PREV_CLI_ENV" | tail -1 | cut -d= -f2- || true; } - -# Роль сервера (несколько серверов под одной панелью, docs/node-protocol.md): -# standalone — панель + VPN на одной машине, как было всегда; может принимать ноды; -# core — только панель, без VPN на этой машине (LOCAL_NODE=off): AmneziaWG, модуль -# ядра и заголовки не ставятся, VPN-серверы подключаются к ней нодами; -# node — VPN без панели: awg-ctrl + awg-agent, который сам подключается к панели -# по строке awgjoin://… («Добавить сервер» в панели). Входящих портов, -# кроме UDP для клиентов, нода не открывает. -# Можно задать окружением: ROLE=node JOIN='awgjoin://…' bash install.sh -# При обновлении роль берётся из прежней установки: смена роли — только переустановкой. -PREV_ROLE="" -if [[ -n "$PREV_CLI_ENV" ]]; then - if [[ -n "$(prev_val CORE_HOST)" ]]; then PREV_ROLE="node" - elif [[ "$(prev_val LOCAL_NODE)" == "off" ]]; then PREV_ROLE="core" - else PREV_ROLE="standalone" - fi -fi -ROLE="${ROLE:-}" -if [[ "$INSTALL_MODE" == "update" ]]; then - if [[ -n "$ROLE" && -n "$PREV_ROLE" && "$ROLE" != "$PREV_ROLE" ]]; then - fail "Смена роли ($PREV_ROLE → $ROLE) при обновлении невозможна — выбери полную переустановку" - fi - ROLE="${PREV_ROLE:-standalone}" -elif [[ -z "$ROLE" ]]; then - case "$PREV_ROLE" in node) ROLE_DEF=3 ;; core) ROLE_DEF=2 ;; *) ROLE_DEF=1 ;; esac - echo - echo -e " ${BLD}Роль сервера${NC}" - echo -e " ${ACC}${BLD}1${NC} Панель + VPN ${DIM}самостоятельный сервер; к нему можно подключать ноды${NC}" - echo -e " ${ACC}${BLD}2${NC} Только панель ${DIM}без VPN на этой машине; серверы подключаются нодами${NC}" - echo -e " ${ACC}${BLD}3${NC} Нода ${DIM}VPN без панели; подключается к уже установленной${NC}" - ask ROLE_CHOICE "Выбери [1–3, Enter — ${ROLE_DEF}]: " - case "${ROLE_CHOICE:-$ROLE_DEF}" in - 1) ROLE="standalone" ;; - 2) ROLE="core" ;; - 3) ROLE="node" ;; - *) fail "Неверный выбор: введи 1, 2 или 3" ;; - esac -fi -[[ "$ROLE" =~ ^(standalone|core|node)$ ]] || fail "ROLE должен быть standalone, core или node (задано: $ROLE)" -case "$ROLE" in - standalone) ROLE_LABEL="панель + VPN" ;; - core) ROLE_LABEL="только панель" ;; - node) ROLE_LABEL="нода" ;; -esac - -# Запускается ДО вопросов конфигурации и любого деструктива (rm -rf): на несовместимой -# машине лучше упасть сразу, а не после ввода данных или удаления каталога. -# Порты (UDP VPN / порт UI) здесь НЕ проверяем — это отдельно (фаервол/облако). -step "Проверка совместимости" - -KERNEL=$(uname -r) -VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown") - -# Семейство дистрибутива решает, как ставить AmneziaWG: -# ubuntu — add-apt-repository ppa:amnezia/ppa, заголовки linux-headers-generic; -# debian — ни software-properties-common (в Debian 13 его нет), ни -# linux-headers-generic там не существует: PPA подключаем вручную -# (ключ + signed-by), заголовки — только под текущее ядро. -# Производные (Mint, Pop!_OS, Kali…) определяются через ID_LIKE. -OS_ID=""; OS_LIKE=""; OS_CODENAME=""; OS_NAME="" -if [[ -r /etc/os-release ]]; then - OS_ID=$(. /etc/os-release; echo "${ID:-}") - OS_LIKE=$(. /etc/os-release; echo "${ID_LIKE:-}") - OS_CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME:-}") - OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-}") -fi -if [[ "$OS_ID" == "ubuntu" || " $OS_LIKE " == *" ubuntu "* ]]; then - OS_FAMILY="ubuntu" -elif [[ "$OS_ID" == "debian" || " $OS_LIKE " == *" debian "* ]]; then - OS_FAMILY="debian" -else - OS_FAMILY="" -fi -# Вариант ядра без версии: 6.12.85+deb13-cloud-amd64 → cloud-amd64, -# 6.1.0-18-cloud-amd64 → cloud-amd64. Нужен для мета-пакетов Debian -# (linux-image-<вариант> / linux-headers-<вариант>). -KFLAVOUR=$(sed -E 's/^[0-9][^-]*-([0-9]+-)?//' <<< "$KERNEL") -# Что советовать, если заголовков под текущее ядро нет. -if [[ "$OS_FAMILY" == "debian" ]]; then - HDR_FIX="apt-get update && apt-get install -y linux-image-$KFLAVOUR linux-headers-$KFLAVOUR && reboot" -else - HDR_FIX="apt-get install -y linux-generic && reboot" -fi - -echo -e " ${DIM}${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)} · ядро $KERNEL · виртуализация $VIRT · роль: $ROLE_LABEL${NC}" - -# Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист -# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим -# с кодом 0 (чистый выход, без вида «упало с ошибкой»). -OS_OK=1; ARCH_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1 -OS_WHY=""; ARCH_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY="" - -# Архитектура: релиз собирается под amd64 и arm64. -if [[ -z "$GOARCH" ]]; then - ARCH_OK=0 - ARCH_WHY="архитектура '$(uname -m)' не поддерживается — сборки есть для x86_64 и aarch64" -fi - -# 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian. -if [[ -z "$OS_FAMILY" ]]; then - OS_OK=0 - OS_WHY="дистрибутив '${OS_NAME:-${OS_ID:-неизвестно}}' не поддерживается — нужен Ubuntu или Debian" -fi - -# Виртуализация и заголовки нужны только для модуля ядра — панели без VPN (core) они не важны, -# её можно ставить и в контейнер. -if [[ "$ROLE" != "core" ]]; then - # 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом. - case "$VIRT" in - openvz|lxc|lxc-libvirt|docker|podman|wsl) - VIRT_OK=0 - VIRT_WHY="виртуализация '$VIRT' — ядро общее с хостом, kernel-модуль AmneziaWG не загрузить (нужен userspace amneziawg-go)" - ;; - esac - - # 2) Заголовки ядра: DKMS соберёт модуль только при их наличии под текущее ядро. - # Уже в системе или есть кандидат в apt — ок. «(none)» — кастомное ядро, не - # проходит. Пустой apt-индекс не валим: добьёт поздняя проверка на шаге AWG. - HDR_POLICY=$(apt-cache policy "linux-headers-$KERNEL" 2>/dev/null || true) - if [[ -d "/lib/modules/$KERNEL/build" ]]; then - : - elif echo "$HDR_POLICY" | grep -q 'Candidate: [^(]'; then - : - elif echo "$HDR_POLICY" | grep -q 'Candidate: (none)'; then - HDR_OK=0 - HDR_WHY="нет заголовков под ядро $KERNEL в apt. Решение: $HDR_FIX, затем запусти install.sh заново" - fi -fi - -# 3) Доступ в интернет: нужен для архива и пакетов AWG. Без -f: любой -# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS. -# Проверяем хосты, с которых реально качаем: хватает любого из двух — -# download_extract() умеет откатываться на запасной. -NET_HOSTS=("$(url_host "$REPO_BASE")") -[[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")") -[[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \ - && NET_HOSTS+=("$(url_host "$REPO_FALLBACK")") -NET_OK=0 -for h in "${NET_HOSTS[@]}"; do - if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi -done -[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS" - -# Чеклист: ✓ — пройдено, ✗ — нет. -mark() { if [[ "$1" == 1 ]]; then echo -e " ${GRN}✓${NC} $2"; else echo -e " ${RED}✗${NC} $2"; fi; } -mark "$OS_OK" "Дистрибутив" -mark "$ARCH_OK" "Архитектура" -if [[ "$ROLE" != "core" ]]; then - mark "$VIRT_OK" "Виртуализация" - mark "$HDR_OK" "Заголовки ядра" -fi -mark "$NET_OK" "Доступ в интернет" - -if [[ "$OS_OK" == 0 || "$ARCH_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then - echo - warn "Установка невозможна — не пройдены проверки:" - [[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY" - [[ "$ARCH_OK" == 0 ]] && echo -e " ${RED}•${NC} $ARCH_WHY" - [[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY" - [[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY" - [[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY" - echo - trap - ERR - exit 0 -fi - -# Наличие базы определяем заранее и молча — от него зависит, спросим ли ниже имя -# сервера (если база есть, имя берётся из неё). Без вывода. KEEP_DATA — отдельно. -DB_EXISTS="n" -[[ -f "$DB_FILE" ]] && DB_EXISTS="y" - -# Параметры обфускации и UDP-порт AWG. На новой установке — случайные, у каждого сервера -# свои: известные всем значения по умолчанию — готовая сигнатура для блокировки. При KEEP_DATA=y -# берутся из прежнего awg1.conf (choose_awg_params ниже): они зашиты во все выданные vpn:// -# ключи, их смена сделала бы каждый ключ невалидным. -# Ограничения генератора (gen_awg_params): -# - Jc 4–8; Jmin < Jmax, оба много меньше MTU -# - S1–S4 ≥ 12: при HeaderProtectionKey (AmneziaWG 3.x) меньшее модуль отвергает с одним -# «Invalid argument» (причина видна лишь при -# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`) -# - размеры пакетов после добавки разные: 148+S1 (init), 92+S2 (response), 64+S3 (cookie) — -# иначе тип пакета снова узнаётся по длине; отсюда же S1 != S2 и S2 != S1+56 -# - S4 добавляется к каждому пакету данных — держим маленьким, как прежние 14 -# - H1–H4: непересекающиеся диапазоны в [10^8, 2^31−1] — далеко от 1–4 (типы сообщений -# WireGuard) и не выше прежних значений (некоторые клиенты разбирают их как int32) -# - I1 здесь не задаётся и в awg1.conf не пишется: он нужен только клиентам, его выбирает -# awg-ctrl (свой у каждого сервера, хранится в users.db — KEEP_DATA его сохраняет; -# internal/ctrl/i1.go). I2–I5 в vpn:// ключ уходят пустыми плейсхолдерами -# Прежние постоянные значения: ими собраны ключи установок до рандомизации — нужны, если -# при KEEP_DATA=y awg1.conf потерялся. -LEGACY_AWG_PORT="47619" -LEGACY_JC=6; LEGACY_JMIN=10; LEGACY_JMAX=50 -LEGACY_S1=90; LEGACY_S2=45; LEGACY_S3=37; LEGACY_S4=14 -LEGACY_H1="1224800044-2116730834" -LEGACY_H2="2122053282-2133204808" -LEGACY_H3="2133604274-2140756116" -LEGACY_H4="2143656228-2147444225" - -rand_int() { shuf -i "$1-$2" -n 1; } - -gen_awg_params() { - JC=$(rand_int 4 8) - JMIN=$(rand_int 8 40) - JMAX=$(( JMIN + $(rand_int 20 80) )) - while :; do - S1=$(rand_int 15 150); S2=$(rand_int 15 150); S3=$(rand_int 15 60) - local a=$(( 148 + S1 )) b=$(( 92 + S2 )) c=$(( 64 + S3 )) - [[ "$S1" != "$S2" && "$a" != "$b" && "$a" != "$c" && "$b" != "$c" ]] && break - done - S4=$(rand_int 12 20) - # 8 разных чисел по возрастанию → 4 непересекающихся диапазона, раздаём H1–H4 вперемешку. - local n r - n=($(shuf -i 100000000-2147483647 -n 8 | sort -n)) - r=($(printf '%s\n' "${n[0]}-${n[1]}" "${n[2]}-${n[3]}" "${n[4]}-${n[5]}" "${n[6]}-${n[7]}" | shuf)) - H1=${r[0]}; H2=${r[1]}; H3=${r[2]}; H4=${r[3]} -} - -legacy_awg_params() { - JC=$LEGACY_JC; JMIN=$LEGACY_JMIN; JMAX=$LEGACY_JMAX - S1=$LEGACY_S1; S2=$LEGACY_S2; S3=$LEGACY_S3; S4=$LEGACY_S4 - H1=$LEGACY_H1; H2=$LEGACY_H2; H3=$LEGACY_H3; H4=$LEGACY_H4 -} - -# Случайный UDP-порт, который никто на сервере не слушает. -gen_awg_port() { - local p - while :; do - p=$(rand_int 20000 59999) - [[ -z "$(ss -Hulnp "sport = :$p" 2>/dev/null)" ]] && { echo "$p"; return; } - done -} - -# Порт и обфускация: при KEEP_DATA=y — из прежнего awg1.conf (все J/S/H разом или ни одного: -# смешивать с новыми нельзя), иначе случайные. AWG_PORT из окружения важнее всего. -choose_awg_params() { - local k prev_port="" - # || true: без файла sed падает, а с pipefail и set -e это молча роняло бы установку. - conf_val() { sed -n "s/^$1 *= *//p" "$AWG_CONF" 2>/dev/null | head -1 || true; } - if [[ "$KEEP_DATA" == "y" ]]; then - JC=$(conf_val Jc); JMIN=$(conf_val Jmin); JMAX=$(conf_val Jmax) - S1=$(conf_val S1); S2=$(conf_val S2); S3=$(conf_val S3); S4=$(conf_val S4) - H1=$(conf_val H1); H2=$(conf_val H2); H3=$(conf_val H3); H4=$(conf_val H4) - for k in "$JC" "$JMIN" "$JMAX" "$S1" "$S2" "$S3" "$S4" "$H1" "$H2" "$H3" "$H4"; do - if [[ -z "$k" ]]; then - legacy_awg_params - warn "Параметры обфускации не прочитаны из $AWG_CONF — взяты прежние постоянные" - break - fi - done - prev_port=$(conf_val ListenPort) - [[ -z "$prev_port" ]] && prev_port=$(prev_val SERVER_PORT) - AWG_PORT="${AWG_PORT:-${prev_port:-$LEGACY_AWG_PORT}}" - else - gen_awg_params - AWG_PORT="${AWG_PORT:-$(gen_awg_port)}" - fi - [[ "$AWG_PORT" =~ ^[0-9]+$ && "$AWG_PORT" -gt 0 && "$AWG_PORT" -lt 65536 ]] \ - || fail "AWG_PORT «${AWG_PORT}» — не номер порта" -} - # Поколение протокола и закрепление версии пакета — управляются переменными # окружения, задаются ДО запуска: `AWG_GEN=auto bash install.sh`. # AWG_GEN=3.1 (по умолчанию) — основное поколение: если модуль в ядре не 3.x @@ -554,10 +260,43 @@ AWG_GEN="${AWG_GEN:-3.1}" # через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым # сервером (issue #215 — именно так словили регрессию между dkms-сборками). AWG_PIN="${AWG_PIN:-y}" +[[ "$AWG_PIN" =~ ^[yn]$ ]] || fail "AWG_PIN должен быть y или n (задано: $AWG_PIN)" -NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') -[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" +# Параметры обфускации, UDP-порт и MTU AWG. На новой установке — случайные, у каждого сервера +# свои: известные всем значения по умолчанию — готовая сигнатура для блокировки. При KEEP_DATA=y +# берутся из прежнего awg1.conf (choose_awg_params ниже): они зашиты во все выданные vpn:// +# ключи, их смена сделала бы каждый ключ невалидным. +# Ограничения генератора (gen_awg_params): +# - Jc 4–8; Jmin < Jmax, оба много меньше MTU +# - S1–S4 ≥ 12: при HeaderProtectionKey (AmneziaWG 3.x) меньшее модуль отвергает с одним +# «Invalid argument» (причина видна лишь при +# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`) +# - размеры пакетов после добавки разные: 148+S1 (init), 92+S2 (response), 64+S3 (cookie) — +# иначе тип пакета снова узнаётся по длине; отсюда же S1 != S2 и S2 != S1+56 +# - S4 добавляется к каждому пакету данных — держим маленьким, как прежние 14 +# - H1–H4: непересекающиеся диапазоны в [10^8, 2^31−1] — далеко от 1–4 (типы сообщений +# WireGuard) и не выше прежних значений (некоторые клиенты разбирают их как int32) +# - I1 — пакет-маскировка, его шлёт инициатор handshake, принимающая сторона просто +# отбрасывает. Сервер его не сверяет: у каждого ключа может быть свой, поэтому при +# KEEP_DATA=y без I1 в конфиге его можно сгенерировать заново — старые ключи не сломаются. +# Формат обязан пройти И kernel-модуль (сервер), И amneziawg-go (клиенты) — см. AWG_I1_RE +# Клиентам awg-ctrl отдаёт свой I1 из users.db (internal/ctrl/i1.go): на первом старте +# берёт этот, дальше его меняют в панели («Мастер-ключи») — в awg1.conf он не пишется +# - I2–I5 НЕ трогать: в vpn:// ключ уходят пустыми плейсхолдерами +# - MTU 1320–1376: не выше прежнего (запас под S4, заголовки и IPv6 на пути), не ниже — +# лишние накладные расходы. Расхождение MTU сервера и клиента WireGuard переносит +# Прежние постоянные значения: ими собраны ключи установок до рандомизации — нужны, если +# при KEEP_DATA=y awg1.conf потерялся. +LEGACY_AWG_PORT="47619" +LEGACY_MTU="1376" +LEGACY_JC=6; LEGACY_JMIN=10; LEGACY_JMAX=50 +LEGACY_S1=90; LEGACY_S2=45; LEGACY_S3=37; LEGACY_S4=14 +LEGACY_H1="1224800044-2116730834" +LEGACY_H2="2122053282-2133204808" +LEGACY_H3="2133604274-2140756116" +LEGACY_H4="2143656228-2147444225" +# ═══ Функции ═══════════════════════════════════════════════════════════════════ # Сначала корректно через CLI (по PID-файлам), затем добиваем всё, что ещё # держит файлы проекта: осиротевшие процессы, ручной запуск или stale PID, # которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе сервисы @@ -589,6 +328,265 @@ kill_related() { rm -f /tmp/awg-ctrl.pid /tmp/awg-ui.pid /tmp/awg-agent.pid 2>/dev/null || true } +# Значение из cli.env прежней установки (PREV_CLI_ENV читается в начале сценария). +prev_val() { grep "^$1=" <<< "$PREV_CLI_ENV" | tail -1 | cut -d= -f2- || true; } +# Прежнее значение — только если данные сохраняются (KEEP_DATA): порт и путь панели зашиты в +# закладки и адрес внешнего API, порты подписки и хаба — в выданные ссылки и строки нод. +keep() { [[ "$KEEP_DATA" == "y" ]] && prev_val "$1" || true; } + +# Пункт чеклиста совместимости: ✓ — пройдено, ✗ — нет. +mark() { if [[ "$1" == 1 ]]; then echo -e " ${GRN}✓${NC} $2"; else echo -e " ${RED}✗${NC} $2"; fi; } + +# ── Полное удаление ───────────────────────────────────────────────────────── +# Убирает всё, что ставили установщик и awg-ctrl: сервисы и юнит, интерфейс awg1, nft-таблицы +# шейпинга и блокировки торрентов, открытые порты в UFW/firewalld/iptables, Prometheus из +# `awg-ctrl monitor install`, каталог проекта, /etc/amnezia (пользователи, ключи, базы), sysctl, +# логи установки (в них пароль панели). AmneziaWG (пакеты, модуль, PPA) — по вопросу: он мог +# стоять и до нас. Общие зависимости (dkms, build-essential, iptables, заголовки) не трогаем. +uninstall_all() { + local role="$PREV_ROLE" conf="$AWG_CONF" net_iface="" p + local awg_port ports=() + local unit="/etc/systemd/system/awg-control.service" + + step "Удаление ${BRAND}" + echo -e " ${DIM}Будут удалены сервисы, $PROJECT, $AMNEZIA_DIR (пользователи, ключи, базы),${NC}" + echo -e " ${DIM}юнит systemd, правила фаервола и логи установки. Выданные ключи перестанут работать.${NC}" + case "$role" in + node) echo -e " ${DIM}Это нода: после удаления убери сервер и в списке серверов панели.${NC}" ;; + *) [[ -n "$(prev_val NODE_PORT)" ]] && \ + echo -e " ${DIM}Подключённые к этой панели ноды останутся без панели — их VPN продолжит работать.${NC}" ;; + esac + echo + if [[ ! "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then + ask CONFIRM "Для подтверждения введи «удалить»: " + [[ "$CONFIRM" == "удалить" ]] || fail "Отменено — ничего не тронуто" + fi + + # Копия данных — по умолчанию да: без неё выданные ключи не восстановить. + if [[ -d "$AMNEZIA_DIR" || -f "$PROJECT/cli/cli.env" ]]; then + ask KEEP_BACKUP "Сохранить копию данных (пользователи, ключи, cli.env) в /root? [Y/n]: " + if [[ "${KEEP_BACKUP:-y}" =~ ^[Yy]$ ]]; then + local bak items=() + bak="/root/forgetting-backup-$(date +%Y%m%d-%H%M%S).tar.gz" + [[ -d "$AMNEZIA_DIR" ]] && items+=("${AMNEZIA_DIR#/}") + [[ -f "$PROJECT/cli/cli.env" ]] && items+=("${PROJECT#/}/cli/cli.env") + ( umask 077; tar -czf "$bak" -C / "${items[@]}" ) \ + && ok "Копия: $bak" \ + || fail "Не удалось сохранить копию — удаление отменено, ничего не тронуто" + fi + fi + + # Что открывали наружу — из прежнего конфига (порты случайные, угадать нельзя). + awg_port=$(sed -n 's/^ListenPort *= *//p' "$conf" 2>/dev/null | head -1 || true) + [[ -z "$awg_port" ]] && awg_port=$(prev_val SERVER_PORT) + [[ -n "$awg_port" ]] && ports+=("${awg_port}/udp") + for p in UI_PORT SUB_PORT NODE_PORT; do + [[ -n "$(prev_val "$p")" ]] && ports+=("$(prev_val "$p")/tcp") + done + net_iface=$(grep -oE 'POSTROUTING -o [^ ]+' "$conf" 2>/dev/null | head -1 | awk '{print $3}' || true) + + info "остановка сервисов" + if command -v systemctl &>/dev/null && [[ -f "$unit" ]]; then + run systemctl disable --now awg-control.service || true + fi + # Prometheus из `awg-ctrl monitor install` — только наш (метка в конфиге); чужой не трогаем. + if [[ -x "$BIN" ]] && grep -qs '^# managed by forgetting' /etc/prometheus/prometheus.yml; then + info "Prometheus, поставленный панелью" + run "$BIN" monitor remove || warn "Prometheus не удалён — sudo apt purge prometheus" + fi + kill_related + ok "Сервисы остановлены" + + if command -v awg &>/dev/null && awg show "$IFACE" &>/dev/null; then + # down выполняет PreDown из конфига — снимает FORWARD/MASQUERADE. + run awg-quick down "$IFACE" || ip link delete dev "$IFACE" 2>/dev/null || true + ok "Интерфейс $IFACE опущен" + fi + if command -v nft &>/dev/null; then + nft delete table inet awg_shape 2>/dev/null || true + nft delete table inet awg_torrent 2>/dev/null || true + fi + + if [[ ${#ports[@]} -gt 0 ]]; then + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then + for p in "${ports[@]}"; do run ufw delete allow "$p" || true; done + [[ -n "$net_iface" ]] && { run ufw route delete allow in on "$IFACE" out on "$net_iface" || true; } + ok "UFW: закрыты ${ports[*]}" + elif command -v firewall-cmd &>/dev/null && firewall-cmd --state &>/dev/null; then + for p in "${ports[@]}"; do run firewall-cmd --permanent --remove-port="$p" || true; done + run firewall-cmd --reload || true + ok "firewalld: закрыты ${ports[*]}" + elif command -v iptables &>/dev/null; then + local removed=0 + for p in "${ports[@]}"; do + while iptables -C INPUT -p "${p#*/}" --dport "${p%/*}" -j ACCEPT 2>/dev/null; do + iptables -D INPUT -p "${p#*/}" --dport "${p%/*}" -j ACCEPT && removed=1 + done + done + if [[ "$removed" == 1 ]]; then + command -v netfilter-persistent &>/dev/null && { run netfilter-persistent save || true; } + ok "iptables: закрыты ${ports[*]}" + fi + fi + fi + + if [[ -f /etc/systemd/system/awg-control.service ]]; then + rm -f /etc/systemd/system/awg-control.service + command -v systemctl &>/dev/null && { run systemctl daemon-reload || true; } + fi + [[ "$(readlink /usr/local/bin/awg-ctrl 2>/dev/null)" == "$BIN" ]] && rm -f /usr/local/bin/awg-ctrl + # Пути — константы из шапки; :? — страховка от пустой переменной. + rm -rf "${PROJECT:?}" "${AMNEZIA_DIR:?}" + rm -f /etc/sysctl.d/99-awg-control.conf + # Логи установок: в них пароль панели. Текущий (удаления) — без секретов, остаётся. + find /var/log -maxdepth 1 -name 'awg-install-*.log' ! -path "$LOGFILE" -delete 2>/dev/null || true + ok "Удалены $PROJECT, $AMNEZIA_DIR, юнит, команда awg-ctrl, логи установки" + + if dpkg -s amneziawg &>/dev/null || dpkg -s amneziawg-tools &>/dev/null; then + echo + ask DROP_AWG "Удалить и AmneziaWG (пакеты, модуль ядра, репозиторий)? [y/N]: " + if [[ "${DROP_AWG:-n}" =~ ^[Yy]$ ]]; then + run apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools || true + rmmod amneziawg 2>/dev/null || true + DEBIAN_FRONTEND=noninteractive run apt-get purge -y amneziawg amneziawg-dkms amneziawg-tools \ + || warn "apt-get purge завершился ошибкой — проверь: dpkg -l | grep amneziawg" + rm -f /etc/apt/sources.list.d/amnezia-ppa.list /etc/apt/keyrings/amnezia-ppa.gpg + if command -v add-apt-repository &>/dev/null \ + && grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/; then + run add-apt-repository -y --remove ppa:amnezia/ppa || true + fi + ok "AmneziaWG удалён" + else + skip "AmneziaWG оставлен" + fi + fi + + echo + card_line "$GRN" + card_line "$GRN" "${GRN}${BLD}✓ ${BRAND} удалён${NC}" + card_line "$GRN" + card_line "$GRN" "${DIM}IP forwarding (sysctl) выключится после перезагрузки — сейчас: sysctl -w net.ipv4.ip_forward=0,${NC}" + card_line "$GRN" "${DIM}если он не нужен другим сервисам. Фаервол хостера (панель провайдера) — закрой порты там сам.${NC}" + card_line "$GRN" + echo +} + +gen_awg_params() { + JC=$(rand_int 4 8) + JMIN=$(rand_int 8 40) + JMAX=$(( JMIN + $(rand_int 20 80) )) + while :; do + S1=$(rand_int 15 150); S2=$(rand_int 15 150); S3=$(rand_int 15 60) + local a=$(( 148 + S1 )) b=$(( 92 + S2 )) c=$(( 64 + S3 )) + [[ "$S1" != "$S2" && "$a" != "$b" && "$a" != "$c" && "$b" != "$c" ]] && break + done + S4=$(rand_int 12 20) + # 8 разных чисел по возрастанию → 4 непересекающихся диапазона, раздаём H1–H4 вперемешку. + local n r + mapfile -t n < <(shuf -i 100000000-2147483647 -n 8 | sort -n) + mapfile -t r < <(printf '%s\n' "${n[0]}-${n[1]}" "${n[2]}-${n[3]}" "${n[4]}-${n[5]}" "${n[6]}-${n[7]}" | shuf) + H1=${r[0]}; H2=${r[1]}; H3=${r[2]}; H4=${r[3]} +} + +# Синтаксис I1, который принимают обе реализации AmneziaWG: +# - только теги , , , , . не знает amneziawg-go (клиент не +# применит ключ), // не знает kernel-модуль; +# - ровно ОДИН пробел между именем тега и аргументом: модуль делит по одиночному пробелу; +# - у префикс 0x обязателен, hex — чётной длины; +# - текст вне <…> обе молча выкидывают — не пишем. +# Ошибка формата роняет весь `awg-quick up`, а модуль говорит лишь «Invalid argument». +AWG_I1_RE='^(|<(r|rc|rd) [1-9][0-9]{0,3}>|)+$' + +# Домены для I1: популярные, чтобы запрос выглядел обычным. +I1_DOMAINS=(google.com youtube.com yandex.ru vk.com mail.ru ok.ru apple.com icloud.com + microsoft.com cloudflare.com wikipedia.org github.com) + +# I1 — DNS-запрос (его шлёт клиент, так что запрос правдоподобнее ответа): — ID, новый +# в каждом пакете; флаги 0100 (стандартный, RD), 1 вопрос; случайный домен, A или AAAA, класс IN. +# 27–36 байт — меньше любого handshake-пакета (самый короткий, cookie, ≥ 79): по длине не путается. +gen_awg_i1() { + local d label qname="" qtype labels + d=${I1_DOMAINS[$(rand_int 0 $(( ${#I1_DOMAINS[@]} - 1 )))]} + [[ $(rand_int 0 1) == 1 ]] && d="www.$d" + IFS=. read -ra labels <<< "$d" + for label in "${labels[@]}"; do + qname+=$(printf '%02x' "${#label}")$(printf '%s' "$label" | od -An -tx1 | tr -d ' \n') + done + qtype=$([[ $(rand_int 0 2) == 0 ]] && echo 001c || echo 0001) + I1="" +} + +legacy_awg_params() { + JC=$LEGACY_JC; JMIN=$LEGACY_JMIN; JMAX=$LEGACY_JMAX + S1=$LEGACY_S1; S2=$LEGACY_S2; S3=$LEGACY_S3; S4=$LEGACY_S4 + H1=$LEGACY_H1; H2=$LEGACY_H2; H3=$LEGACY_H3; H4=$LEGACY_H4 +} + +# Случайный UDP-порт, который никто на сервере не слушает. +gen_awg_port() { + local p + while :; do + p=$(rand_int 20000 59999) + [[ -z "$(ss -Hulnp "sport = :$p" 2>/dev/null)" ]] && { echo "$p"; return; } + done +} + +# Порт, MTU и обфускация: при KEEP_DATA=y — из прежнего awg1.conf (все J/S/H разом или ни +# одного: смешивать с новыми нельзя), иначе случайные. AWG_PORT и AWG_MTU из окружения +# важнее всего. +choose_awg_params() { + local k prev_port="" + # || true: без файла sed падает, а с pipefail и set -e это молча роняло бы установку. + conf_val() { sed -n "s/^$1 *= *//p" "$AWG_CONF" 2>/dev/null | head -1 || true; } + if [[ "$KEEP_DATA" == "y" ]]; then + JC=$(conf_val Jc); JMIN=$(conf_val Jmin); JMAX=$(conf_val Jmax) + S1=$(conf_val S1); S2=$(conf_val S2); S3=$(conf_val S3); S4=$(conf_val S4) + H1=$(conf_val H1); H2=$(conf_val H2); H3=$(conf_val H3); H4=$(conf_val H4) + for k in "$JC" "$JMIN" "$JMAX" "$S1" "$S2" "$S3" "$S4" "$H1" "$H2" "$H3" "$H4"; do + if [[ -z "$k" ]]; then + legacy_awg_params + warn "Параметры обфускации не прочитаны из $AWG_CONF — взяты прежние постоянные" + break + fi + done + prev_port=$(conf_val ListenPort) + [[ -z "$prev_port" ]] && prev_port=$(prev_val SERVER_PORT) + AWG_PORT="${AWG_PORT:-${prev_port:-$LEGACY_AWG_PORT}}" + MTU="${MTU:-$(conf_val MTU)}" + MTU="${MTU:-$LEGACY_MTU}" + I1=$(conf_val I1) + if [[ -n "$I1" && ! "$I1" =~ $AWG_I1_RE ]]; then + warn "I1 из $AWG_CONF не пройдёт парсер AmneziaWG — генерируем новый" + I1="" + fi + # Конфиги до рандомизации I1 не содержат: ключи брали общий из awg-ctrl. Новый + # безопасен — сервер I1 не сверяет, у старых ключей останется прежний. + [[ -z "$I1" ]] && gen_awg_i1 + else + gen_awg_params + gen_awg_i1 + AWG_PORT="${AWG_PORT:-$(gen_awg_port)}" + MTU="${MTU:-$(rand_int 1320 1376)}" + fi + [[ "$AWG_PORT" =~ ^[0-9]+$ && "$AWG_PORT" -gt 0 && "$AWG_PORT" -lt 65536 ]] \ + || fail "AWG_PORT «${AWG_PORT}» — не номер порта" + [[ "$MTU" =~ ^[0-9]+$ && "$MTU" -ge 1280 && "$MTU" -le 1500 ]] \ + || fail "MTU «${MTU}» — нужно число 1280–1500" + [[ "$I1" =~ $AWG_I1_RE ]] || fail "I1 «${I1}» не пройдёт парсер AmneziaWG" +} + +valid_port() { [[ "$1" =~ ^[0-9]+$ && "$1" -gt 0 && "$1" -lt 65536 ]]; } + +# Случайный свободный TCP-порт 32768–55535, не из уже занятых установкой (аргументы). +gen_tcp_port() { + local p used=" $* " + while :; do + p=$(rand_int 32768 55535) + [[ "$used" == *" $p "* ]] && continue + [[ -z "$(ss -Htlnp "sport = :$p" 2>/dev/null)" ]] && { echo "$p"; return; } + done +} + # Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые # файлы на месте. download_extract() { @@ -716,140 +714,6 @@ start_and_status() { "$BIN" status } -if [[ "$INSTALL_MODE" == "update" ]]; then - step "Обновление до версии ${VERSION} ($ROLE_LABEL)" - - info "останавливаем и убиваем все процессы awg-control" - kill_related - - download_extract - install_command - start_and_status - - echo - card_line "$GRN" - card_line "$GRN" "${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${BRAND} · ${ROLE_LABEL}${NC}" - card_line "$GRN" - echo - exit 0 -fi - -# При полной переустановке убиваем все процессы awg-control и очищаем старый -# каталог, чтобы не оставалось ни запущенных процессов, ни старых файлов. -if [[ -d "$PROJECT" ]]; then - kill_related - rm -rf "$PROJECT" -fi - -step "Конфигурация" - -# Server IP определяем автоматически (внешний через ifconfig.me, иначе локальный -# по маршруту) — не спрашиваем. Если определить не удалось — падаем. -SERVER_IP=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null \ - || ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' \ - || echo "") -[[ -z "$SERVER_IP" ]] && fail "Не удалось определить Server IP автоматически" -ok "Server IP: $SERVER_IP" - -# Имя сервера спрашиваем ниже — только если базы ещё нет (DB_EXISTS=n). -# Если база есть, имя берётся из неё. - -# Внутренняя авторизация awg-ui ↔ awg-ctrl — асимметричная пара, генерируется -# в шаге 4 (Ключи). Здесь секрет не нужен. -AWGCTRL_PORT=$(( (RANDOM % 22768) + 32768 )) - -# Панель (awg-ui) есть везде, кроме ноды. -if [[ "$ROLE" != "node" ]]; then - ask UI_PORT "UI port (Enter — случайный): " - UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}" - - ask UI_USER "UI логин [admin]: " - UI_USER="${UI_USER:-admin}" - - SUGGESTED_PASS=$(rand_alnum 12) - ask UI_PASS "UI пароль [$SUGGESTED_PASS]: " secret - UI_PASS="${UI_PASS:-$SUGGESTED_PASS}" - - JWT_SECRET=$(rand_alnum 32) -fi - -# Что сохранять при переустановке. -# С VPN (standalone, node): база пользователей и ключ сервера — только вместе: -# vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними. -# Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи). -KEEP_DATA="n" -if [[ "$WIPE" == "y" ]]; then - : # устаревшая установка: сохранять нечего, сброс уже подтверждён -elif [[ "$ROLE" == "core" ]]; then - if [[ -f "$UI_DB_FILE" ]]; then - echo - warn "Найдены данные панели: $UI_DB_FILE" - ask KD "Сохранить серверы, ключи API и мастер-ключи? [Y/n]: " - [[ "${KD:-y}" =~ ^[Yy]$ || -z "${KD}" ]] && KEEP_DATA="y" - fi -elif [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then - echo - warn "Найдена существующая установка:" - [[ -f "$DB_FILE" ]] && echo " база пользователей: $DB_FILE" - [[ -f "$PRIV_KEY_FILE" ]] && echo " ключ сервера: $PRIV_KEY_FILE" - - if [[ -f "$DB_FILE" && -f "$PRIV_KEY_FILE" && -f "$PUB_KEY_FILE" ]]; then - ask KD "Сохранить пользователей и ключ сервера? [Y/n]: " - [[ "${KD:-y}" =~ ^[Yy]$ || -z "${KD}" ]] && KEEP_DATA="y" - else - warn "Для сохранения нужны и база, и оба ключа сервера — часть отсутствует." - warn "Пользователей не сохранить (vpn:// ключи стали бы невалидными)." - fi -fi - -# sen://-подписка и приём нод живут в панели — у ноды их нет. -# Порты и режим TLS зашиты в уже выданные ссылки, поэтому при KEEP_DATA=y берём прежние -# значения из cli.env прежней установки (PREV_CLI_ENV). -SUB_PORT="${SUB_PORT:-}" -SUB_TLS="${SUB_TLS:-}" -NODE_PORT="${NODE_PORT:-}" -if [[ "$ROLE" != "node" ]]; then - if [[ "$KEEP_DATA" == "y" ]]; then - [[ -z "$SUB_PORT" ]] && SUB_PORT="$(prev_val SUB_PORT)" - [[ -z "$SUB_TLS" ]] && SUB_TLS="$(prev_val SUB_TLS)" - [[ -z "$NODE_PORT" ]] && NODE_PORT="$(prev_val NODE_PORT)" - fi - if [[ -z "$SUB_PORT" ]]; then - ask SUB_PORT "Sub port для sen:// (Enter — случайный): " - while [[ -z "$SUB_PORT" || "$SUB_PORT" == "$UI_PORT" || "$SUB_PORT" == "$AWGCTRL_PORT" ]]; do - SUB_PORT=$(( (RANDOM % 22768) + 32768 )) - done - fi - if [[ -z "$SUB_TLS" ]]; then - ask ST "Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " - [[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on" - fi - [[ "$SUB_TLS" == "on" ]] || SUB_TLS="off" - - # Приём нод: ноды сами подключаются к панели по WSS на этот порт. Сертификат хаба awg-ui - # создаёт при первом запуске, его отпечаток зашит в строки подключения. Панели без VPN - # приём нод обязателен (иначе ей нечем управлять), у «панели + VPN» — по желанию. - if [[ -z "$NODE_PORT" ]]; then - if [[ "$ROLE" == "core" ]]; then - ask NODE_PORT "Порт для подключения нод (Enter — случайный): " - else - ask NODE_PORT "Порт для подключения нод (Enter — не принимать ноды): " - fi - fi - if [[ -n "$NODE_PORT" ]] && ! [[ "$NODE_PORT" =~ ^[0-9]+$ && "$NODE_PORT" -gt 0 && "$NODE_PORT" -lt 65536 ]]; then - [[ "$ROLE" == "core" ]] && fail "«$NODE_PORT» — не номер порта" - warn "NODE_PORT «$NODE_PORT» не порт — приём нод выключен" - NODE_PORT="" - fi - if [[ "$ROLE" == "core" ]]; then - while [[ -z "$NODE_PORT" || "$NODE_PORT" == "$UI_PORT" || "$NODE_PORT" == "$SUB_PORT" ]]; do - NODE_PORT=$(( (RANDOM % 22768) + 32768 )) - done - fi -fi - -[[ "$ROLE" != "core" ]] && choose_awg_params - # Нода: к какой панели подключаться. Строку awgjoin://… даёт панель («Добавить сервер»): # base64url от JSON {v, h, p, pin, n, s}. Разбираем здесь, до установки, чтобы опечатка не # обнаружилась через десять минут сборки модуля. Значения в строке ограничены @@ -885,6 +749,580 @@ core_pin() { | base64 | tr '+/' '-_' | tr -d '=\n' || true } +# ── AmneziaWG: PPA для Debian, проверки модуля и ядра ─────────────────────── +# Debian: add-apt-repository нет (software-properties-common из Debian 13 убран), +# а PPA под Debian-релизы не собирается — подключаем его вручную на Ubuntu-серию +# с glibc не новее дебиановской. Модуль приезжает DKMS-исходниками и от серии +# не зависит; серия важна только для бинарника amneziawg-tools. +# AMNEZIA_PPA_SUITE — задать серию вручную (focal/jammy/noble). +AMNEZIA_PPA_URL="https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu" +AMNEZIA_PPA_FPR="75C9DD72C799870E310542E24166F2C257290828" # Launchpad PPA for Iurii Egorov +AMNEZIA_KEYRING="/etc/apt/keyrings/amnezia-ppa.gpg" +add_amnezia_ppa_debian() { + local suite="${AMNEZIA_PPA_SUITE:-}" + if [[ -z "$suite" ]]; then + case "$OS_CODENAME" in + bullseye) suite="focal" ;; + bookworm) suite="jammy" ;; + trixie) suite="noble" ;; + *) suite="noble" + warn "Debian '${OS_CODENAME:-неизвестно}' не сопоставлен с серией PPA — берём noble (задать вручную: AMNEZIA_PPA_SUITE=…)" ;; + esac + fi + info "подключение PPA amnezia (Debian ${OS_CODENAME:-?} → серия $suite)" + + # Ключ берём по полному отпечатку и сверяем отпечаток после загрузки — + # apt-key в Debian 12+ уже нет, а доверять ответу keyserver вслепую нельзя. + local tmp gh fprs="" + tmp=$(mktemp); gh=$(mktemp -d) + if curl -fsSL --connect-timeout 15 \ + "https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x${AMNEZIA_PPA_FPR}" \ + | gpg --dearmor > "$tmp" 2>/dev/null; then + fprs=$(GNUPGHOME="$gh" gpg --batch --show-keys --with-colons "$tmp" 2>/dev/null || true) + fi + if ! grep -q "^fpr:::::::::${AMNEZIA_PPA_FPR}:" <<< "$fprs"; then + rm -rf "$tmp" "$gh" + fail "Не удалось получить ключ PPA amnezia ($AMNEZIA_PPA_FPR) с keyserver.ubuntu.com" + fi + install -d -m 0755 /etc/apt/keyrings + install -m 0644 "$tmp" "$AMNEZIA_KEYRING" + rm -rf "$tmp" "$gh" + + echo "deb [signed-by=$AMNEZIA_KEYRING] $AMNEZIA_PPA_URL $suite main" \ + > /etc/apt/sources.list.d/amnezia-ppa.list + ok "PPA amnezia подключён: $suite, ключ $AMNEZIA_KEYRING" +} + +# Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5. +# - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0, +# поэтому сначала пробуем обновиться. +# - ядро: header protection использует библиотечный chacha-API +# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся +# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. +# При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий +# откат подсунул бы более слабую обфускацию незаметно для оператора. +# При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение +# по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно +# как раньше, а перейти на 3.1 можно потом из панели. + +kernel_lt_5_5() { + local maj min + maj=${KERNEL%%.*} + min=${KERNEL#*.}; min=${min%%.*} + [[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]] +} + +# 🛑 Версий у модуля две, и путать их нельзя: +# modinfo — что лежит на диске (после apt/DKMS); +# /sys/module/amneziawg/version — что реально загружено в ядро. +# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный. +# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый +# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify +# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля. +disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; } +loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; } + +# Закрывает ли голый iptables входящие: политика DROP или правило DROP/REJECT в INPUT. +iptables_input_closed() { + command -v iptables &>/dev/null || return 1 + iptables -S INPUT 2>/dev/null | grep -qE '^-P INPUT DROP|^-A INPUT .*-j (DROP|REJECT)' +} + +# Открыть OPEN_PORTS на фаерволе самого сервера: UFW, firewalld или голый iptables — что активно. +# Фаервол хостера (панель провайдера, security group) отсюда не виден — о нём напоминаем в итоге. +open_firewall() { + local P + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then + # UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец + # цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT + # дело не доходит и трафик клиентов наружу режется: в dmesg сыплется + # «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет». + # Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно. + info "UFW активен, открываем $([[ "$ROLE" != "core" ]] && echo "маршрут и ")порты" + if [[ "$ROLE" != "core" ]]; then + run ufw route allow in on "$IFACE" out on "$NET_IFACE" \ + || warn "ufw route allow не сработал — трафик клиентов может резаться" + fi + for P in "${OPEN_PORTS[@]}"; do + run ufw allow "$P" || warn "не удалось открыть $P" + done + run ufw reload || true + ok "UFW:$([[ "$ROLE" != "core" ]] && echo " форвардинг $IFACE → $NET_IFACE,") порты ${OPEN_PORTS[*]}" + elif command -v firewall-cmd &>/dev/null && firewall-cmd --state &>/dev/null; then + info "firewalld активен, открываем порты" + for P in "${OPEN_PORTS[@]}"; do + run firewall-cmd --permanent --add-port="$P" || warn "не удалось открыть $P" + done + run firewall-cmd --reload || true + ok "firewalld: порты ${OPEN_PORTS[*]}" + if [[ "$ROLE" != "core" ]]; then + warn "Если VPN подключается, а интернета нет — firewalld режет форвардинг:" + warn " firewall-cmd --permanent --zone=trusted --add-interface=${IFACE} && firewall-cmd --reload" + fi + elif iptables_input_closed; then + # Голый iptables без обёрток: вставляем ACCEPT в начало INPUT (повторно не дублируем). + info "iptables закрывает входящие, открываем порты" + for P in "${OPEN_PORTS[@]}"; do + iptables -C INPUT -p "${P#*/}" --dport "${P%/*}" -j ACCEPT 2>/dev/null \ + || run iptables -I INPUT -p "${P#*/}" --dport "${P%/*}" -j ACCEPT \ + || warn "не удалось открыть $P" + done + if command -v netfilter-persistent &>/dev/null; then + run netfilter-persistent save || warn "правила iptables не сохранены — после перезагрузки порты закроются" + ok "iptables: порты ${OPEN_PORTS[*]} (сохранено)" + else + ok "iptables: порты ${OPEN_PORTS[*]}" + warn "Правила iptables не переживут перезагрузку: apt install iptables-persistent && netfilter-persistent save" + fi + else + info "Фаервол на сервере входящие не закрывает — открывать нечего" + fi +} + +# Адрес панели для итога: с секретным путём. +panel_url() { echo "http://$1:$2/${3:+$3/}"; } + +# ═══ Сценарий ══════════════════════════════════════════════════════════════════ + +echo +card_line "$ACC" +card_line "$ACC" "${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" +card_line "$ACC" "${DIM}Панель управления VPN на AmneziaWG — установка${NC}" +card_line "$ACC" +card_line "$ACC" "${DIM}лог $LOGFILE${NC}" +card_line "$ACC" + +# Поверх существующей установки: обновить (настройки, пользователи и роль остаются), +# переустановить с нуля или удалить совсем. Спрашиваем первым — от ответа зависит, спрашивать +# ли роль. Без меню: ACTION=update|reinstall|uninstall (так запускают пункты меню awg-ctrl) +# или UNINSTALL=y — удалить без вопроса-подтверждения. +# +# Установку старше MIN_UPGRADE_VERSION не обновить и не перенести: с ней несовместимы и данные, +# и выданные ключи. Для неё — только удаление или полная переустановка со сбросом всех данных +# (WIPE=y): пользователи, мастер-ключи, ключи API, ключи сервера и ноды — всё заново. Старые базы +# уходят в бэкап (*.bak-…), как при обычной переустановке, но перенести их нельзя. Версия — из +# .env прежней установки (он есть с 0.2.0); нет его — версия неизвестна и тоже считается устаревшей. +MIN_UPGRADE_VERSION="0.5.0" +version_lt() { [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]]; } +WIPE="n" +OUTDATED="n" +PREV_VERSION="" +if [[ -d "$PROJECT" ]]; then + PREV_VERSION=$(sed -n 's/^VERSION=//p' "$PROJECT/.env" 2>/dev/null | head -1 | tr -d "\"' " || true) + if [[ -z "$PREV_VERSION" ]] || version_lt "${PREV_VERSION%%-*}" "$MIN_UPGRADE_VERSION"; then + OUTDATED="y" + fi +fi +outdated_card() { + echo + card_line "$RED" + card_line "$RED" "${RED}${BLD}✗ Установлена устаревшая версия ${PREV_VERSION:-(неизвестна)}${NC}" + card_line "$RED" + card_line "$RED" "Обновить её до ${VERSION} нельзя: версии старше ${MIN_UPGRADE_VERSION} нужна полная переустановка." + card_line "$RED" "Будут удалены все ключи и данные:" + card_line "$RED" " ${DIM}·${NC} пользователи и их vpn:// ключи" + card_line "$RED" " ${DIM}·${NC} мастер-ключи и ссылки sen://, ключи API" + card_line "$RED" " ${DIM}·${NC} ключи сервера и подключение к панели (ноду придётся добавить заново)" + card_line "$RED" + card_line "$RED" "Выданные ключи перестанут работать — клиентам нужно будет выдать новые." + card_line "$RED" "${DIM}Старые базы останутся в бэкапе (*.bak-…), но перенести их в ${VERSION} нельзя.${NC}" + card_line "$RED" + echo +} +INSTALL_MODE="fresh" +ACTION="${ACTION:-}" +[[ "$ACTION" =~ ^(|update|reinstall|uninstall)$ ]] \ + || fail "ACTION должен быть update, reinstall или uninstall (задано: $ACTION)" +if [[ "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then + INSTALL_MODE="uninstall" +elif [[ -n "$ACTION" ]]; then + [[ -d "$PROJECT" || "$ACTION" == "uninstall" ]] || fail "Обновлять нечего: в $PROJECT ничего не установлено" + case "$ACTION" in + update) INSTALL_MODE="update" ;; + reinstall) INSTALL_MODE="fresh" ;; + uninstall) INSTALL_MODE="uninstall" ;; + esac + if [[ "$OUTDATED" == "y" && "$INSTALL_MODE" != "uninstall" ]]; then + outdated_card + ask WIPE_YN "Переустановить с удалением всех ключей? [y/N]: " + [[ "${WIPE_YN:-n}" =~ ^[Yy]$ ]] || { echo " Отменено — ничего не изменено."; exit 0; } + INSTALL_MODE="fresh"; WIPE="y" + fi +elif [[ -d "$PROJECT" && "$OUTDATED" == "y" ]]; then + outdated_card + echo -e " ${ACC}${BLD}1${NC} Переустановить ${DIM}с удалением всех ключей и данных${NC}" + echo -e " ${ACC}${BLD}2${NC} Удалить ${DIM}полностью: сервисы, данные, правила фаервола, по желанию AmneziaWG${NC}" + ask INST_CHOICE "Выбери [1/2, Enter — отменить]: " + case "${INST_CHOICE:-}" in + 1) INSTALL_MODE="fresh"; WIPE="y" ;; + 2) INSTALL_MODE="uninstall" ;; + *) echo " Отменено — ничего не изменено."; exit 0 ;; + esac + echo +elif [[ -d "$PROJECT" ]]; then + echo + warn "Уже установлено в $PROJECT ${DIM}(версия ${PREV_VERSION})${NC}" + echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" + echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" + echo -e " ${ACC}${BLD}3${NC} Удалить ${DIM}полностью: сервисы, данные, правила фаервола, по желанию AmneziaWG${NC}" + ask INST_CHOICE "Выбери [1–3, Enter — 1]: " + case "${INST_CHOICE:-1}" in + 1) INSTALL_MODE="update" ;; + 2) INSTALL_MODE="fresh" ;; + 3) INSTALL_MODE="uninstall" ;; + *) fail "Неверный выбор: введи 1, 2 или 3" ;; + esac + echo +fi + +# cli.env прежней установки читаем сразу: при переустановке каталог проекта удаляется +# раньше, чем до него доходит дело, а из него берутся роль и порты, зашитые в уже +# выданные ссылки (SUB_PORT, NODE_PORT) и в подключение ноды к панели. +PREV_CLI_ENV="" +[[ -f "$PROJECT/cli/cli.env" ]] && PREV_CLI_ENV=$(cat "$PROJECT/cli/cli.env") +PREV_ROLE="" +if [[ -n "$PREV_CLI_ENV" ]]; then + if [[ -n "$(prev_val CORE_HOST)" ]]; then PREV_ROLE="node" + elif [[ "$(prev_val LOCAL_NODE)" == "off" ]]; then PREV_ROLE="core" + else PREV_ROLE="standalone" + fi +fi + +if [[ "$INSTALL_MODE" == "uninstall" ]]; then + uninstall_all + exit 0 +fi +# Роль сервера (несколько серверов под одной панелью — README, «Несколько серверов»): +# standalone — панель + VPN на одной машине, как было всегда; может принимать ноды; +# core — только панель, без VPN на этой машине (LOCAL_NODE=off): AmneziaWG, модуль +# ядра и заголовки не ставятся, VPN-серверы подключаются к ней нодами; +# node — VPN без панели: awg-ctrl + awg-agent, который сам подключается к панели +# по строке awgjoin://… («Добавить сервер» в панели). Входящих портов, +# кроме UDP для клиентов, нода не открывает. +# Можно задать окружением: ROLE=node JOIN='awgjoin://…' bash install.sh +# При обновлении роль берётся из прежней установки: смена роли — только переустановкой. +ROLE="${ROLE:-}" +if [[ "$INSTALL_MODE" == "update" ]]; then + if [[ -n "$ROLE" && -n "$PREV_ROLE" && "$ROLE" != "$PREV_ROLE" ]]; then + fail "Смена роли ($PREV_ROLE → $ROLE) при обновлении невозможна — выбери полную переустановку" + fi + ROLE="${PREV_ROLE:-standalone}" +elif [[ -z "$ROLE" ]]; then + case "$PREV_ROLE" in node) ROLE_DEF=3 ;; core) ROLE_DEF=2 ;; *) ROLE_DEF=1 ;; esac + echo + echo -e " ${BLD}Роль сервера${NC}" + echo -e " ${ACC}${BLD}1${NC} Панель + VPN ${DIM}самостоятельный сервер; к нему можно подключать ноды${NC}" + echo -e " ${ACC}${BLD}2${NC} Только панель ${DIM}без VPN на этой машине; серверы подключаются нодами${NC}" + echo -e " ${ACC}${BLD}3${NC} Нода ${DIM}VPN без панели; подключается к уже установленной${NC}" + ask ROLE_CHOICE "Выбери [1–3, Enter — ${ROLE_DEF}]: " + case "${ROLE_CHOICE:-$ROLE_DEF}" in + 1) ROLE="standalone" ;; + 2) ROLE="core" ;; + 3) ROLE="node" ;; + *) fail "Неверный выбор: введи 1, 2 или 3" ;; + esac +fi +[[ "$ROLE" =~ ^(standalone|core|node)$ ]] || fail "ROLE должен быть standalone, core или node (задано: $ROLE)" +case "$ROLE" in + standalone) ROLE_LABEL="панель + VPN" ;; + core) ROLE_LABEL="только панель" ;; + node) ROLE_LABEL="нода" ;; +esac + +# Запускается ДО вопросов конфигурации и любого деструктива (rm -rf): на несовместимой +# машине лучше упасть сразу, а не после ввода данных или удаления каталога. +# Порты (UDP VPN / порт UI) здесь НЕ проверяем — это отдельно (фаервол/облако). +step "Проверка совместимости" + +KERNEL=$(uname -r) +VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown") + +# Семейство дистрибутива решает, как ставить AmneziaWG: +# ubuntu — add-apt-repository ppa:amnezia/ppa, заголовки linux-headers-generic; +# debian — ни software-properties-common (в Debian 13 его нет), ни +# linux-headers-generic там не существует: PPA подключаем вручную +# (ключ + signed-by), заголовки — только под текущее ядро. +# Производные (Mint, Pop!_OS, Kali…) определяются через ID_LIKE. +OS_ID=""; OS_LIKE=""; OS_CODENAME=""; OS_NAME="" +if [[ -r /etc/os-release ]]; then + OS_ID=$(. /etc/os-release; echo "${ID:-}") + OS_LIKE=$(. /etc/os-release; echo "${ID_LIKE:-}") + OS_CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME:-}") + OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-}") +fi +if [[ "$OS_ID" == "ubuntu" || " $OS_LIKE " == *" ubuntu "* ]]; then + OS_FAMILY="ubuntu" +elif [[ "$OS_ID" == "debian" || " $OS_LIKE " == *" debian "* ]]; then + OS_FAMILY="debian" +else + OS_FAMILY="" +fi +# Вариант ядра без версии: 6.12.85+deb13-cloud-amd64 → cloud-amd64, +# 6.1.0-18-cloud-amd64 → cloud-amd64. Нужен для мета-пакетов Debian +# (linux-image-<вариант> / linux-headers-<вариант>). +KFLAVOUR=$(sed -E 's/^[0-9][^-]*-([0-9]+-)?//' <<< "$KERNEL") +# Что советовать, если заголовков под текущее ядро нет. +if [[ "$OS_FAMILY" == "debian" ]]; then + HDR_FIX="apt-get update && apt-get install -y linux-image-$KFLAVOUR linux-headers-$KFLAVOUR && reboot" +else + HDR_FIX="apt-get install -y linux-generic && reboot" +fi + +echo -e " ${DIM}${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)} · ядро $KERNEL · виртуализация $VIRT · роль: $ROLE_LABEL${NC}" + +# Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист +# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим +# с кодом 0 (чистый выход, без вида «упало с ошибкой»). +OS_OK=1; ARCH_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1 +OS_WHY=""; ARCH_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY="" + +# Архитектура: релиз собирается под amd64 и arm64. +if [[ -z "$GOARCH" ]]; then + ARCH_OK=0 + ARCH_WHY="архитектура '$(uname -m)' не поддерживается — сборки есть для x86_64 и aarch64" +fi + +# 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian. +if [[ -z "$OS_FAMILY" ]]; then + OS_OK=0 + OS_WHY="дистрибутив '${OS_NAME:-${OS_ID:-неизвестно}}' не поддерживается — нужен Ubuntu или Debian" +fi + +# Виртуализация и заголовки нужны только для модуля ядра — панели без VPN (core) они не важны, +# её можно ставить и в контейнер. +if [[ "$ROLE" != "core" ]]; then + # 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом. + case "$VIRT" in + openvz|lxc|lxc-libvirt|docker|podman|wsl) + VIRT_OK=0 + VIRT_WHY="виртуализация '$VIRT' — ядро общее с хостом, kernel-модуль AmneziaWG не загрузить (нужен userspace amneziawg-go)" + ;; + esac + + # 2) Заголовки ядра: DKMS соберёт модуль только при их наличии под текущее ядро. + # Уже в системе или есть кандидат в apt — ок. «(none)» — кастомное ядро, не + # проходит. Пустой apt-индекс не валим: добьёт поздняя проверка на шаге AWG. + HDR_POLICY=$(apt-cache policy "linux-headers-$KERNEL" 2>/dev/null || true) + if [[ -d "/lib/modules/$KERNEL/build" ]]; then + : + elif echo "$HDR_POLICY" | grep -q 'Candidate: [^(]'; then + : + elif echo "$HDR_POLICY" | grep -q 'Candidate: (none)'; then + HDR_OK=0 + HDR_WHY="нет заголовков под ядро $KERNEL в apt. Решение: $HDR_FIX, затем запусти install.sh заново" + fi +fi + +# 3) Доступ в интернет: нужен для архива и пакетов AWG. Без -f: любой +# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS. +# Проверяем хосты, с которых реально качаем: хватает любого из двух — +# download_extract() умеет откатываться на запасной. +NET_HOSTS=("$(url_host "$REPO_BASE")") +[[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")") +[[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \ + && NET_HOSTS+=("$(url_host "$REPO_FALLBACK")") +NET_OK=0 +for h in "${NET_HOSTS[@]}"; do + if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi +done +[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS" + +mark "$OS_OK" "Дистрибутив" +mark "$ARCH_OK" "Архитектура" +if [[ "$ROLE" != "core" ]]; then + mark "$VIRT_OK" "Виртуализация" + mark "$HDR_OK" "Заголовки ядра" +fi +mark "$NET_OK" "Доступ в интернет" + +if [[ "$OS_OK" == 0 || "$ARCH_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then + echo + warn "Установка невозможна — не пройдены проверки:" + [[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY" + [[ "$ARCH_OK" == 0 ]] && echo -e " ${RED}•${NC} $ARCH_WHY" + [[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY" + [[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY" + [[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY" + echo + trap - ERR + exit 0 +fi + +NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') +[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" + +# ── Обновление: новая версия поверх, настройки и данные на месте ────────────── +if [[ "$INSTALL_MODE" == "update" ]]; then + step "Обновление до версии ${VERSION} ($ROLE_LABEL)" + + info "останавливаем и убиваем все процессы awg-control" + kill_related + + download_extract + install_command + + # Установке без секретного пути панели — выдаём его (UI_PREFIX=off — оставить без пути): + # иначе обновлённый сервер по-прежнему узнаётся сканером по странице логина. cli.env + # обновление не переписывает, поэтому дописываем одну строку. + UI_PREFIX_NEW="n" + UI_PREFIX="${UI_PREFIX:-}" + if [[ "$ROLE" != "node" ]]; then + CLI_ENV="$PROJECT/cli/cli.env" + if ! grep -q '^UI_PREFIX=' "$CLI_ENV" 2>/dev/null && [[ "$UI_PREFIX" != "off" ]]; then + UI_PREFIX="${UI_PREFIX:-$(rand_alnum 16)}" + [[ "$UI_PREFIX" =~ ^[A-Za-z0-9_-]{8,64}$ ]] \ + || fail "UI_PREFIX «${UI_PREFIX}» — нужно 8–64 символа A–Z a–z 0–9 _ -" + printf 'UI_PREFIX=%s\n' "$UI_PREFIX" >> "$CLI_ENV" + UI_PREFIX_NEW="y" + ok "Панели выдан секретный путь" + fi + UI_PREFIX=$(sed -n 's/^UI_PREFIX=//p' "$CLI_ENV" 2>/dev/null | tail -1) + fi + + start_and_status + + echo + card_line "$GRN" + card_line "$GRN" "${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${BRAND} · ${ROLE_LABEL}${NC}" + card_line "$GRN" + if [[ "$ROLE" != "node" ]]; then + HOST="$(prev_val SUB_HOST)"; HOST="${HOST:-$(prev_val SERVER_IP)}" + card_line "$GRN" "${DIM}Панель ${NC} ${BLD}$(panel_url "${HOST:-IP}" "$(prev_val UI_PORT)" "$UI_PREFIX")${NC}" + if [[ "$UI_PREFIX_NEW" == "y" ]]; then + card_line "$GRN" " ${YLW}путь новый: без него панель отвечает 404 — обнови закладки и адрес${NC}" + card_line "$GRN" " ${YLW}внешнего API (/${UI_PREFIX}/api/v1/…). Вернуть как было: UI_PREFIX= в cli.env${NC}" + fi + card_line "$GRN" + fi + echo + exit 0 +fi + +step "Конфигурация" + +# Внешний IPv4 — через ifconfig.me, иначе адрес, с которого сервер ходит наружу. Не спрашиваем. +SERVER_IP=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || true) +if ! [[ "$SERVER_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + SERVER_IP=$(ip route get 1.1.1.1 2>/dev/null | awk '{for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}') +fi +[[ -n "$SERVER_IP" ]] || fail "Не удалось определить адрес сервера" +ok "Адрес сервера: $SERVER_IP" + +# Что сохранять при переустановке — спрашиваем первым: от ответа зависят значения по умолчанию. +# С VPN (standalone, node): база пользователей и ключ сервера — только вместе: +# vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними. +# Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи). +KEEP_DATA="n" +if [[ "$WIPE" == "y" ]]; then + : # устаревшая установка: сохранять нечего, сброс уже подтверждён +elif [[ "$ROLE" == "core" ]]; then + if [[ -f "$UI_DB_FILE" ]]; then + echo + warn "Найдены данные панели: $UI_DB_FILE" + ask KD "Сохранить серверы, ключи API и мастер-ключи? [Y/n]: " + [[ "${KD:-y}" =~ ^[Yy]$ ]] && KEEP_DATA="y" + fi +elif [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then + echo + warn "Найдена существующая установка:" + [[ -f "$DB_FILE" ]] && echo " база пользователей: $DB_FILE" + [[ -f "$PRIV_KEY_FILE" ]] && echo " ключ сервера: $PRIV_KEY_FILE" + + if [[ -f "$DB_FILE" && -f "$PRIV_KEY_FILE" && -f "$PUB_KEY_FILE" ]]; then + ask KD "Сохранить пользователей и ключ сервера? [Y/n]: " + [[ "${KD:-y}" =~ ^[Yy]$ ]] && KEEP_DATA="y" + else + warn "Для сохранения нужны и база, и оба ключа сервера — часть отсутствует." + warn "Пользователей не сохранить (vpn:// ключи стали бы невалидными)." + fi +fi + +# Панель (awg-ui), подписка sen:// и приём нод есть везде, кроме ноды. Значения из окружения +# (UI_PORT=…, SUB_PORT=…) важнее и не спрашиваются. +UI_PORT="${UI_PORT:-}"; UI_USER="${UI_USER:-}"; UI_PASS="${UI_PASS:-}" +SUB_PORT="${SUB_PORT:-}"; SUB_TLS="${SUB_TLS:-}"; NODE_PORT="${NODE_PORT:-}" +JWT_SECRET="" +if [[ "$ROLE" != "node" ]]; then + DEF=$(keep UI_PORT); DEF="${DEF:-$(gen_tcp_port)}" + ask_def UI_PORT "Порт панели [$DEF]: " "$DEF" + valid_port "$UI_PORT" || fail "Порт панели «$UI_PORT» — не номер порта" + + DEF=$(keep UI_USER); DEF="${DEF:-admin}" + ask_def UI_USER "Логин панели [$DEF]: " "$DEF" + + DEF=$(keep UI_PASS) + if [[ -n "$DEF" ]]; then + ask_def UI_PASS "Пароль панели [Enter — прежний]: " "$DEF" secret + else + DEF=$(rand_alnum 12) + ask_def UI_PASS "Пароль панели [$DEF]: " "$DEF" secret + fi + # Прежний секрет — чтобы открытые вкладки панели не разлогинило. + JWT_SECRET=$(keep JWT_SECRET); JWT_SECRET="${JWT_SECRET:-$(rand_alnum 32)}" + + SUB_PORT="${SUB_PORT:-$(keep SUB_PORT)}" + if [[ -z "$SUB_PORT" ]]; then + DEF=$(gen_tcp_port "$UI_PORT") + ask_def SUB_PORT "Порт подписки sen:// [$DEF]: " "$DEF" + fi + valid_port "$SUB_PORT" || fail "Порт подписки «$SUB_PORT» — не номер порта" + [[ "$SUB_PORT" != "$UI_PORT" ]] || fail "Порт подписки совпадает с портом панели ($UI_PORT)" + + SUB_TLS="${SUB_TLS:-$(keep SUB_TLS)}" + if [[ -z "$SUB_TLS" ]]; then + ask ST "Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " + [[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on" + fi + [[ "$SUB_TLS" == "on" ]] || SUB_TLS="off" + + # Приём нод: ноды сами подключаются к панели по WSS на этот порт. Сертификат хаба awg-ui + # создаёт при первом запуске, его отпечаток зашит в строки подключения. Панели без VPN + # приём нод обязателен (иначе ей нечем управлять), у «панели + VPN» — по желанию. + NODE_PORT="${NODE_PORT:-$(keep NODE_PORT)}" + if [[ -z "$NODE_PORT" ]]; then + if [[ "$ROLE" == "core" ]]; then + DEF=$(gen_tcp_port "$UI_PORT" "$SUB_PORT") + ask_def NODE_PORT "Порт для подключения нод [$DEF]: " "$DEF" + else + ask NODE_PORT "Порт для подключения нод (Enter — не принимать ноды, «+» — случайный): " + [[ "$NODE_PORT" == "+" ]] && NODE_PORT=$(gen_tcp_port "$UI_PORT" "$SUB_PORT") + fi + fi + if [[ -n "$NODE_PORT" ]]; then + valid_port "$NODE_PORT" || fail "Порт для нод «$NODE_PORT» — не номер порта" + [[ "$NODE_PORT" != "$UI_PORT" && "$NODE_PORT" != "$SUB_PORT" ]] \ + || fail "Порт для нод совпадает с портом панели или подписки" + fi +fi +# awg-ctrl слушает только loopback; порт внутренний, при каждой установке новый. +AWGCTRL_PORT=$(gen_tcp_port "${UI_PORT:-}" "${SUB_PORT:-}" "${NODE_PORT:-}") + +# Порт, MTU и параметры обфускации VPN (choose_awg_params). +[[ "$ROLE" != "core" ]] && choose_awg_params + +if [[ "$ROLE" != "node" ]]; then + # Секретный путь панели: снаружи она отвечает только на http://IP:PORT/<путь>/, на остальное — + # 404, как пустой сервер. Без него сканер узнавал панель по странице логина одним GET. + # При переустановке путь прежний; UI_PREFIX=off — без пути, UI_PREFIX=<свой> — свой + # (8–64 символа A–Z a–z 0–9 _ -). + UI_PREFIX="${UI_PREFIX:-}" + UI_PREFIX_NEW="n" + if [[ "$UI_PREFIX" == "off" ]]; then + UI_PREFIX="" + elif [[ -z "$UI_PREFIX" ]]; then + UI_PREFIX=$(keep UI_PREFIX) + if [[ -z "$UI_PREFIX" ]]; then + UI_PREFIX=$(rand_alnum 16) + [[ -n "$PREV_CLI_ENV" ]] && UI_PREFIX_NEW="y" + fi + fi + [[ -z "$UI_PREFIX" || "$UI_PREFIX" =~ ^[A-Za-z0-9_-]{8,64}$ ]] \ + || fail "UI_PREFIX «${UI_PREFIX}» — нужно 8–64 символа A–Z a–z 0–9 _ -" + # Путь переехавшей сюда панели (migrate) — её закладкам и API через ретранслятор. + UI_PREFIX_MOVED=$(keep UI_PREFIX_MOVED) +fi + +# Нода: к какой панели подключаться (строка awgjoin://… из «Добавить сервер» в панели). CORE_HOST=""; CORE_PORT=""; CORE_PIN=""; NODE_ID=""; JOIN_SECRET="" if [[ "$ROLE" == "node" ]]; then JOIN="${JOIN:-}" @@ -918,17 +1356,19 @@ if [[ "$ROLE" == "node" ]]; then fi fi -# Имя сервера — только там, где есть VPN. Если база уже существует (DB_EXISTS, проверено -# заранее в preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем. -SERVER_NAME="VPN" +# Имя сервера — только там, где есть VPN. При сохранённой базе имя хранится в ней (его меняют +# в панели) — не спрашиваем; иначе спрашиваем, по умолчанию прежнее. +SERVER_NAME="${SERVER_NAME:-}" if [[ "$ROLE" != "core" ]]; then - if [[ "$DB_EXISTS" == "y" ]]; then - echo " Server name: берётся из существующей базы (пропускаем)" + if [[ "$KEEP_DATA" == "y" ]]; then + SERVER_NAME="${SERVER_NAME:-$(prev_val SERVER_NAME)}" + info "Имя сервера — из сохранённой базы" else - ask SERVER_NAME "Server name [VPN]: " - SERVER_NAME="${SERVER_NAME:-VPN}" + DEF=$(prev_val SERVER_NAME); DEF="${DEF:-VPN}" + ask_def SERVER_NAME "Имя сервера [$DEF]: " "$DEF" fi fi +SERVER_NAME="${SERVER_NAME:-VPN}" # Сводка. Метки выровнены пробелами прямо в строках: printf в bash считает ширину в байтах, # и кириллица поехала бы. @@ -939,10 +1379,10 @@ card_line "$ACC" "${DIM}Роль ${NC} ${BLD}$ROLE_LABEL${NC}" card_line "$ACC" "${DIM}Адрес ${NC} $SERVER_IP ${DIM}· интерфейс $NET_IFACE${NC}" if [[ "$ROLE" != "core" ]]; then card_line "$ACC" "${DIM}Имя сервера ${NC} $SERVER_NAME" - card_line "$ACC" "${DIM}VPN ${NC} ${AWG_PORT}/udp" + card_line "$ACC" "${DIM}VPN ${NC} ${AWG_PORT}/udp ${DIM}· MTU ${MTU}${NC}" fi if [[ "$ROLE" != "node" ]]; then - card_line "$ACC" "${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER${NC}" + card_line "$ACC" "${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER · $([[ -n "$UI_PREFIX" ]] && echo "секретный путь" || echo "без секретного пути")${NC}" card_line "$ACC" "${DIM}Подписка sen://${NC} ${SUB_PORT}/tcp ${DIM}· $([[ "$SUB_TLS" == "on" ]] && echo "HTTPS" || echo "HTTP, ответы подписаны")${NC}" if [[ -n "$NODE_PORT" ]]; then card_line "$ACC" "${DIM}Приём нод ${NC} ${NODE_PORT}/tcp" @@ -970,6 +1410,14 @@ echo ask YN "Продолжить? [Y/n]: " [[ "${YN:-y}" =~ ^[Nn]$ ]] && { echo " Отменено."; exit 0; } +# Переустановка: прежние процессы и каталог убираем только после подтверждения — отказ на +# «Продолжить?» не должен оставлять сервер без установки. Данные (/etc/amnezia) на месте, +# cli.env прочитан в начале (PREV_CLI_ENV). +if [[ -d "$PROJECT" ]]; then + kill_related + rm -rf "${PROJECT:?}" +fi + # Совместимость (виртуализация, заголовки ядра, интернет) проверена выше в # секции «Проверка совместимости»; $KERNEL задан там же. step "1/6 AmneziaWG" @@ -978,49 +1426,6 @@ if [[ "$ROLE" == "core" ]]; then skip "пропущено — у панели без VPN AmneziaWG не нужен" else - # Debian: add-apt-repository нет (software-properties-common из Debian 13 убран), - # а PPA под Debian-релизы не собирается — подключаем его вручную на Ubuntu-серию - # с glibc не новее дебиановской. Модуль приезжает DKMS-исходниками и от серии - # не зависит; серия важна только для бинарника amneziawg-tools. - # AMNEZIA_PPA_SUITE — задать серию вручную (focal/jammy/noble). - AMNEZIA_PPA_URL="https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu" - AMNEZIA_PPA_FPR="75C9DD72C799870E310542E24166F2C257290828" # Launchpad PPA for Iurii Egorov - AMNEZIA_KEYRING="/etc/apt/keyrings/amnezia-ppa.gpg" - add_amnezia_ppa_debian() { - local suite="${AMNEZIA_PPA_SUITE:-}" - if [[ -z "$suite" ]]; then - case "$OS_CODENAME" in - bullseye) suite="focal" ;; - bookworm) suite="jammy" ;; - trixie) suite="noble" ;; - *) suite="noble" - warn "Debian '${OS_CODENAME:-неизвестно}' не сопоставлен с серией PPA — берём noble (задать вручную: AMNEZIA_PPA_SUITE=…)" ;; - esac - fi - info "подключение PPA amnezia (Debian ${OS_CODENAME:-?} → серия $suite)" - - # Ключ берём по полному отпечатку и сверяем отпечаток после загрузки — - # apt-key в Debian 12+ уже нет, а доверять ответу keyserver вслепую нельзя. - local tmp gh fprs="" - tmp=$(mktemp); gh=$(mktemp -d) - if curl -fsSL --connect-timeout 15 \ - "https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x${AMNEZIA_PPA_FPR}" \ - | gpg --dearmor > "$tmp" 2>/dev/null; then - fprs=$(GNUPGHOME="$gh" gpg --batch --show-keys --with-colons "$tmp" 2>/dev/null || true) - fi - if ! grep -q "^fpr:::::::::${AMNEZIA_PPA_FPR}:" <<< "$fprs"; then - rm -rf "$tmp" "$gh" - fail "Не удалось получить ключ PPA amnezia ($AMNEZIA_PPA_FPR) с keyserver.ubuntu.com" - fi - install -d -m 0755 /etc/apt/keyrings - install -m 0644 "$tmp" "$AMNEZIA_KEYRING" - rm -rf "$tmp" "$gh" - - echo "deb [signed-by=$AMNEZIA_KEYRING] $AMNEZIA_PPA_URL $suite main" \ - > /etc/apt/sources.list.d/amnezia-ppa.list - ok "PPA amnezia подключён: $suite, ключ $AMNEZIA_KEYRING" - } - if command -v awg &>/dev/null && command -v awg-quick &>/dev/null && modinfo amneziawg &>/dev/null; then ok "AWG уже установлен (модуль amneziawg: $(modinfo -F version amneziawg 2>/dev/null || echo present))" else @@ -1108,34 +1513,6 @@ else ok "AWG установлен, модуль amneziawg собран и загружается" fi - # Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5. - # - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0, - # поэтому сначала пробуем обновиться. - # - ядро: header protection использует библиотечный chacha-API - # (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся - # (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. - # При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий - # откат подсунул бы более слабую обфускацию незаметно для оператора. - # При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение - # по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно - # как раньше, а перейти на 3.1 можно потом из панели. - - kernel_lt_5_5() { - local maj min - maj=${KERNEL%%.*} - min=${KERNEL#*.}; min=${min%%.*} - [[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]] - } - - # 🛑 Версий у модуля две, и путать их нельзя: - # modinfo — что лежит на диске (после apt/DKMS); - # /sys/module/amneziawg/version — что реально загружено в ядро. - # `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный. - # Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый - # загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify - # interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля. - disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; } - loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; } if [[ "$AWG_GEN" == "2.0" ]]; then AWG3="n" @@ -1278,14 +1655,16 @@ if [[ "$ROLE" != "node" ]]; then chmod 600 "$SUB_SIGN_PRIV" ok "Ключ подписи sen:// создан" fi + # Имя в сертификате случайное: одинаковое у всех серверов было бы сигнатурой для сканера. + # Клиент сверяет pin ключа, а не имя. if [[ "$SUB_TLS" == "on" ]]; then if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then ok "Сертификат подписки sen:// сохранён" else - # CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI. ( umask 077 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ - -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=$(openssl rand -hex 6)" 2>/dev/null ) + -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 \ + -subj "/CN=$(rand_alnum 10 | tr 'A-Z' 'a-z').net" 2>/dev/null ) chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT" ok "Самоподписанный сертификат подписки sen:// создан (10 лет)" fi @@ -1355,6 +1734,7 @@ H1 = ${H1} H2 = ${H2} H3 = ${H3} H4 = ${H4} +I1 = ${I1} CONF [[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$AWG_CONF" @@ -1457,73 +1837,19 @@ else ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")" fi +step "5/6 Фаервол" + # Порты, которые должны быть доступны снаружи. Нода открывает только UDP для клиентов: к панели # она подключается сама. OPEN_PORTS=() [[ "$ROLE" != "core" ]] && OPEN_PORTS+=("${AWG_PORT}/udp") [[ "$ROLE" != "node" ]] && OPEN_PORTS+=("${UI_PORT}/tcp" "${SUB_PORT}/tcp") [[ "$ROLE" != "node" && -n "$NODE_PORT" ]] && OPEN_PORTS+=("${NODE_PORT}/tcp") - -# Закрывает ли голый iptables входящие: политика DROP или правило DROP/REJECT в INPUT. -iptables_input_closed() { - command -v iptables &>/dev/null || return 1 - iptables -S INPUT 2>/dev/null | grep -qE '^-P INPUT DROP|^-A INPUT .*-j (DROP|REJECT)' -} - -# Фаервол на самом сервере. Фаервол хостера (панель провайдера, security group) отсюда не -# виден — о нём напоминаем в итоге установки. -if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then - # UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец - # цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT - # дело не доходит и трафик клиентов наружу режется: в dmesg сыплется - # «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет». - # Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно. - info "UFW активен, открываем $([[ "$ROLE" != "core" ]] && echo "маршрут и ")порты" - if [[ "$ROLE" != "core" ]]; then - run ufw route allow in on "$IFACE" out on "$NET_IFACE" \ - || warn "ufw route allow не сработал — трафик клиентов может резаться" - fi - for P in "${OPEN_PORTS[@]}"; do - run ufw allow "$P" || warn "не удалось открыть $P" - done - run ufw reload || true - ok "UFW:$([[ "$ROLE" != "core" ]] && echo " форвардинг $IFACE → $NET_IFACE,") порты ${OPEN_PORTS[*]}" -elif command -v firewall-cmd &>/dev/null && firewall-cmd --state &>/dev/null; then - info "firewalld активен, открываем порты" - for P in "${OPEN_PORTS[@]}"; do - run firewall-cmd --permanent --add-port="$P" || warn "не удалось открыть $P" - done - run firewall-cmd --reload || true - ok "firewalld: порты ${OPEN_PORTS[*]}" - if [[ "$ROLE" != "core" ]]; then - warn "Если VPN подключается, а интернета нет — firewalld режет форвардинг:" - warn " firewall-cmd --permanent --zone=trusted --add-interface=${IFACE} && firewall-cmd --reload" - fi -elif iptables_input_closed; then - # Голый iptables без обёрток: вставляем ACCEPT в начало INPUT (повторно не дублируем). - info "iptables закрывает входящие, открываем порты" - for P in "${OPEN_PORTS[@]}"; do - iptables -C INPUT -p "${P#*/}" --dport "${P%/*}" -j ACCEPT 2>/dev/null \ - || run iptables -I INPUT -p "${P#*/}" --dport "${P%/*}" -j ACCEPT \ - || warn "не удалось открыть $P" - done - if command -v netfilter-persistent &>/dev/null; then - run netfilter-persistent save || warn "правила iptables не сохранены — после перезагрузки порты закроются" - ok "iptables: порты ${OPEN_PORTS[*]} (сохранено)" - else - ok "iptables: порты ${OPEN_PORTS[*]}" - warn "Правила iptables не переживут перезагрузку: apt install iptables-persistent && netfilter-persistent save" - fi -else - info "Фаервол на сервере входящие не закрывает — открывать нечего" -fi - -step "5/6 Команда awg-ctrl" - -install_command +open_firewall step "6/6 Запуск сервисов" +install_command # cli.env по роли. Роль CLI узнаёт отсюда же: CORE_HOST → нода (awg-ctrl + awg-agent), # LOCAL_NODE=off → панель без VPN (только awg-ui), иначе — awg-ctrl + awg-ui. { @@ -1554,6 +1880,8 @@ cat <] — адрес панели, секретный путь (UI_PREFIX) +// log [seen] [поиск] [-n N] — журнал VPN-подключений этого сервера, «кто когда был» +// metrics [token|revoke] — токен «только метрики» для своего Prometheus +// hub [legacy on|off|auto] — хаб нод: подпись до апгрейда, кто из нод ещё старый +// check — что на этом сервере выдаёт его сканеру и DPI + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "database/sql" + "encoding/pem" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/maeneko/forgetting/internal/migrate" +) + +// pad — дополнение пробелами по числу символов, не байт: в Printf %-Ns кириллица съезжает. +func pad(s string, n int) string { + if l := len([]rune(s)); l < n { + return s + strings.Repeat(" ", n-l) + } + return s +} + +func yellow(s string) string { return "\x1b[33m" + s + cReset } + +func (c *CLI) hasPanel() bool { return contains(c.all, "ui") || env("UI_PORT", "") != "" && !c.isNode } +func (c *CLI) hasVPN() bool { return contains(c.all, "awgctrl") } + +func (c *CLI) uiDB() (*sql.DB, error) { + f := c.svcEnv("ui", "UI_DB_FILE") + if !exists(f) { + return nil, fmt.Errorf("базы панели нет (%s)", f) + } + return migrate.OpenDB(f) +} + +// restartUI — cli.env изменён: панель перезапускается с новым значением, VPN не трогаем. +func (c *CLI) restartUI(key, value string) { + c.setSvcEnv("ui", key, value) + if c.isRunning("ui") { + c.stop("ui") + c.start("ui") + } +} + +func when(ts int64) string { + t := time.Unix(ts, 0) + now := time.Now() + switch { + case t.YearDay() == now.YearDay() && t.Year() == now.Year(): + return "сегодня " + t.Format("15:04") + case t.Year() == now.Year(): + return t.Format("02.01 15:04") + } + return t.Format("02.01.2006 15:04") +} + +func dur(s int64) string { + switch { + case s < 60: + return fmt.Sprintf("%d сек", s) + case s < 3600: + return fmt.Sprintf("%d мин", s/60) + case s < 86400: + return fmt.Sprintf("%d ч %d мин", s/3600, s%3600/60) + } + return fmt.Sprintf("%d дн %d ч", s/86400, s%86400/3600) +} + +func bytesStr(n float64) string { + switch { + case n < 1<<10: + return fmt.Sprintf("%.0f B", n) + case n < 1<<20: + return fmt.Sprintf("%.1f KB", n/(1<<10)) + case n < 1<<30: + return fmt.Sprintf("%.1f MB", n/(1<<20)) + } + return fmt.Sprintf("%.2f GB", n/(1<<30)) +} + +// ── panel ─────────────────────────────────────────────────────────────────── + +var rePrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + +func randPrefix() string { + const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 16) + rand.Read(b) + for i := range b { + b[i] = abc[int(b[i])%len(abc)] + } + return string(b) +} + +func (c *CLI) panelCmd(args []string) { + if !c.hasPanel() { + fmt.Println(" Панели на этой машине нет — это нода") + return + } + if len(args) == 0 { + c.showPanel() + return + } + if args[0] != "path" || len(args) < 2 { + errorf(" Usage: awg-ctrl panel [path new|off|<путь>]") + os.Exit(1) + } + c.setPanelPath(args[1]) +} + +func (c *CLI) showPanel() { + fmt.Printf("\n Панель %s\n", bold(panelURL())) + if p := env("UI_PREFIX", ""); p != "" { + fmt.Println(" " + dim("Секретный путь: снаружи без него — 404. Сменить: awg-ctrl panel path new")) + } else { + fmt.Println(" " + yellow("Без секретного пути: сканер узнаёт панель по странице логина.") + " " + dim("Включить: awg-ctrl panel path new")) + } + if m := env("UI_PREFIX_MOVED", ""); m != "" { + fmt.Println(" " + dim("Принимается и путь переехавшей панели: /"+m+"/")) + } + fmt.Println() +} + +// setPanelPath — new (случайный), off (без пути) или свой. Закладки и адрес внешнего API +// со старым путём перестанут работать — предупреждаем. +func (c *CLI) setPanelPath(arg string) { + v := arg + switch arg { + case "new": + v = randPrefix() + case "off": + v = "" + default: + if !rePrefix.MatchString(arg) { + errorf(" Путь: 8–64 символа A–Z a–z 0–9 _ -, или new / off") + os.Exit(1) + } + } + if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "UI_PREFIX", v)); err != nil { + errorf(" Не удалось записать %s: %v", c.envFile, err) + os.Exit(1) + } + os.Setenv("UI_PREFIX", v) + c.restartUI("UI_PREFIX", v) + fmt.Printf("\n %s Панель: %s\n", green("✓"), bold(panelURL())) + fmt.Println(" " + dim("Старый адрес больше не открывается: обновите закладки и адрес внешнего API и Prometheus.")) + fmt.Println() +} + +// ── log ───────────────────────────────────────────────────────────────────── + +var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`) + +// ownerName — «устройство · ключ» для пира sen://, если его панель — на этой машине. +func (c *CLI) ownerName(db *sql.DB, owner string) string { + m := reOwner.FindStringSubmatch(owner) + if m == nil || db == nil || m[1] != "" && m[1] != c.localPanelID() { + return owner + } + var label, dev string + db.QueryRow("SELECT label FROM master_keys WHERE id = ?", m[2]).Scan(&label) + db.QueryRow("SELECT device_name FROM devices WHERE id = ?", m[3]).Scan(&dev) + switch { + case dev != "" && label != "": + return dev + " · «" + label + "»" + case label != "": + return "удалённое устройство · «" + label + "»" + } + return "удалённое устройство" +} + +func (c *CLI) logCmd(args []string) { + if !c.hasVPN() { + fmt.Println(" VPN на этой машине нет — журнал серверов во вкладке «Подключения» панели") + return + } + seen, limit, q := false, 50, "" + for i := 0; i < len(args); i++ { + switch a := args[i]; { + case a == "seen": + seen = true + case a == "-n" && i+1 < len(args): + n, err := strconv.Atoi(args[i+1]) + if err != nil || n <= 0 { + errorf(" -n: число событий") + os.Exit(1) + } + limit, i = min(n, 500), i+1 + default: + q = strings.TrimSpace(q + " " + a) + } + } + v := url.Values{"limit": {strconv.Itoa(limit)}} + if q != "" && !seen { + v.Set("q", q) + } + r, err := c.ctrlCall("GET", "/api/connlog?"+v.Encode(), nil) + if err != nil { + errorf(" awg-ctrl недоступен: %v", err) + os.Exit(1) + } + if r.status == 404 { + errorf(" awg-ctrl этой версии журнала не ведёт — обновите сервер: awg-ctrl update") + os.Exit(1) + } + if r.status != 200 { + errorf(" %v", ctrlError(r)) + os.Exit(1) + } + db, _ := c.uiDB() + if db != nil { + defer db.Close() + } + who := func(o any) (string, string) { + name, _ := get(o, "name").(string) + ip, _ := get(o, "ip").(string) + if name == "" { + if owner, _ := get(o, "owner").(string); owner != "" { + name = c.ownerName(db, owner) + } else { + pub, _ := get(o, "pub_key").(string) + name = "ключ " + pub[:min(10, len(pub))] + "…" + } + } + return name, ip + } + fmt.Println() + if since, ok := get(r.data, "since").(float64); ok { + fmt.Printf(" %s\n\n", dim(fmt.Sprintf("Журнал ведётся с %s, хранится %v дней. Время отключения — последняя активность (±30 с).", + when(int64(since)), get(r.data, "keep_days")))) + } + + if seen { + type row struct { + name, ip string + online bool + last int64 + } + var rows []row + peers, _ := get(r.data, "peers").([]any) + for _, p := range peers { + name, ip := who(p) + if q != "" && !strings.Contains(strings.ToLower(name+" "+ip), strings.ToLower(q)) { + continue + } + last, _ := get(p, "last_seen").(float64) + online, _ := get(p, "online").(bool) + rows = append(rows, row{name, ip, online, int64(last)}) + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].online != rows[j].online { + return rows[i].online + } + return rows[i].last > rows[j].last + }) + for _, x := range rows { + state := dim("не подключался с начала журнала") + switch { + case x.online: + state = green("онлайн") + case x.last > 0: + state = "был " + when(x.last) + dim(" ("+dur(time.Now().Unix()-x.last)+" назад)") + } + fmt.Printf(" %s %s %s\n", pad(x.name, 32), dim(pad(x.ip, 12)), state) + } + if len(rows) == 0 { + fmt.Println(" " + dim("Никого")) + } + fmt.Println() + return + } + + events, _ := get(r.data, "events").([]any) + if len(events) == 0 { + fmt.Println(" " + dim("Событий нет")) + } + // Старые сверху: читается как лента. + for i := len(events) - 1; i >= 0; i-- { + e := events[i] + at, _ := get(e, "at").(float64) + name, ip := who(e) + mark, tail := green("→ подключился"), "" + if get(e, "event") == "disconnect" { + mark = dim("← отключился ") + d, _ := get(e, "duration").(float64) + rx, _ := get(e, "rx").(float64) + tx, _ := get(e, "tx").(float64) + tail = dim(fmt.Sprintf(" %s · ↓ %s ↑ %s", dur(int64(d)), bytesStr(tx), bytesStr(rx))) + } + fmt.Printf(" %s %s %s %s%s\n", pad(when(int64(at)), 15), mark, name, dim(ip), tail) + } + fmt.Printf("\n %s\n\n", dim("Больше: awg-ctrl log -n 200 · по имени: awg-ctrl log <имя> · кто когда был: awg-ctrl log seen")) +} + +// ── metrics ───────────────────────────────────────────────────────────────── + +func (c *CLI) metricsCmd(args []string) { + if !c.hasPanel() { + fmt.Println(" Панели на этой машине нет — токен метрик выдаёт панель") + return + } + db, err := c.uiDB() + if err != nil { + errorf(" %v", err) + os.Exit(1) + } + defer db.Close() + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "": + var prefix string + var created int64 + var last sql.NullInt64 + if db.QueryRow("SELECT prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&prefix, &created, &last) != nil { + fmt.Print("\n Токена для внешнего Prometheus нет. Выдать: awg-ctrl metrics token\n\n") + return + } + used := "ещё не было" + if last.Valid && last.Int64 > 0 { + used = when(last.Int64) + } + fmt.Printf("\n Токен %s · выдан %s · последний скрейп %s\n", bold(prefix), when(created), used) + fmt.Print(" " + dim("Перевыпустить: awg-ctrl metrics token · отозвать: awg-ctrl metrics revoke") + "\n\n") + case "token": + b := make([]byte, 24) + rand.Read(b) + tok := "awgm_" + b64url.EncodeToString(b) + h := sha256.Sum256([]byte(tok)) + if _, err := db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)", + h[:], tok[:13]+"…", time.Now().Unix()); err != nil { + errorf(" ui.db: %v (панель этой версии ещё не создала таблицу — запустите её)", err) + os.Exit(1) + } + u, _ := url.Parse(panelURL()) + fmt.Printf("\n %s Токен (показывается один раз, прежний больше не работает):\n\n %s\n\n", green("✓"), bold(tok)) + fmt.Print(" Фрагмент prometheus.yml:\n\n") + fmt.Printf(" scrape_configs:\n - job_name: vpn-panel\n metrics_path: %smetrics\n authorization:\n credentials: %s\n static_configs:\n - targets: ['%s']\n\n", + u.Path, tok, u.Host) + fmt.Print(" " + dim("По голому HTTP токен и метрики идут открытым текстом — скрейпьте через nginx с TLS или по VPN.") + "\n\n") + case "revoke": + db.Exec("DELETE FROM metrics_token WHERE id = 1") + fmt.Printf(" %s Токен отозван\n", green("✓")) + default: + errorf(" Usage: awg-ctrl metrics [token|revoke]") + os.Exit(1) + } +} + +// ── hub ───────────────────────────────────────────────────────────────────── + +func (c *CLI) hubCmd(args []string) { + if !c.hasPanel() || env("NODE_PORT", "") == "" { + fmt.Println(" Хаб нод на этой машине не включён (NODE_PORT)") + return + } + if len(args) > 0 { + if args[0] != "legacy" || len(args) < 2 || !regexp.MustCompile(`^(on|off|auto)$`).MatchString(args[1]) { + errorf(" Usage: awg-ctrl hub [legacy on|off|auto]") + os.Exit(1) + } + v := args[1] + if v == "auto" { + v = "" + } + if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "HUB_LEGACY", v)); err != nil { + errorf(" %v", err) + os.Exit(1) + } + os.Setenv("HUB_LEGACY", v) + c.restartUI("HUB_LEGACY", v) + fmt.Printf(" %s HUB_LEGACY=%s\n", green("✓"), args[1]) + } + db, err := c.uiDB() + if err != nil { + errorf(" %v", err) + os.Exit(1) + } + defer db.Close() + rows, err := db.Query("SELECT id, name, COALESCE(version, ''), COALESCE(last_seen, 0), COALESCE(preauth, 0), pub IS NOT NULL FROM nodes ORDER BY id") + if err != nil { + errorf(" ui.db: %v", err) + os.Exit(1) + } + type node struct { + id int64 + name, version string + seen int64 + preauth, joined bool + } + var list []node + for rows.Next() { + var n node + if rows.Scan(&n.id, &n.name, &n.version, &n.seen, &n.preauth, &n.joined) == nil { + list = append(list, n) + } + } + rows.Close() + old := 0 + fmt.Printf("\n Хаб нод :%s\n\n", env("NODE_PORT", "")) + for _, n := range list { + state := green(pad("с подписью", 28)) + if !n.preauth { + state, old = yellow(pad("старый агент — без подписи", 28)), old+1 + if !n.joined { + state = dim(pad("ещё не подключалась", 28)) + } + } + seen := dim("не подключалась") + if n.seen > 0 { + seen = "был " + when(n.seen) + } + fmt.Printf(" #%-3d %s %s %s %s\n", n.id, pad(n.name, 20), dim(pad(n.version, 12)), state, seen) + } + if len(list) == 0 { + fmt.Println(" " + dim("Нод нет")) + } + mode := env("HUB_LEGACY", "") + fmt.Println() + switch { + case mode == "on": + fmt.Println(" " + yellow("HUB_LEGACY=on: хаб пускает агентов без подписи — сканер узнаёт его по /node/v1")) + case mode == "off": + fmt.Println(" HUB_LEGACY=off: только с подписью. Старые агенты не подключатся, пока ноду не обновят") + case old > 0: + fmt.Println(" " + yellow(fmt.Sprintf("Переходный режим: %d нод(ы) без подписи — хаб пускает и без неё. Обновите их: awg-ctrl update на ноде", old))) + default: + fmt.Println(" " + green("Все ноды с подписью — без неё хаб отвечает 404")) + } + fmt.Print(" " + dim("Принудительно: awg-ctrl hub legacy on|off|auto") + "\n\n") +} + +// ── check ─────────────────────────────────────────────────────────────────── + +// Прежние общие параметры установок до рандомизации — по ним сервер узнаётся DPI. +var legacyAWG = map[string]string{"ListenPort": "47619", "S1": "90", "S2": "45", "H1": "1224800044-2116730834"} + +func readConf(file string) map[string]string { + out := map[string]string{} + raw, err := os.ReadFile(file) + if err != nil { + return nil + } + for _, l := range strings.Split(strings.SplitN(string(raw), "[Peer]", 2)[0], "\n") { + if k, v, ok := strings.Cut(l, "="); ok { + out[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return out +} + +func certCN(file string) string { + raw, err := os.ReadFile(file) + if err != nil { + return "" + } + blk, _ := pem.Decode(raw) + if blk == nil { + return "" + } + crt, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + return "" + } + return crt.Subject.CommonName +} + +// checkCmd — сводка: что на этом сервере выдаёт его сканеру (активное зондирование) и DPI. +func (c *CLI) checkCmd() { + ok := func(s string) { fmt.Println(" " + green("✓") + " " + s) } + bad := func(s, fix string) { fmt.Println(" " + yellow("⚠") + " " + s + "\n " + dim(fix)) } + fmt.Println() + if c.hasVPN() { + conf := readConf(filepath.Join(env("AWG_CONF_DIR", "/etc/amnezia/amneziawg"), "awg1.conf")) + switch { + case conf == nil: + bad("awg1.conf не прочитан", "запустите от root") + default: + var same []string + for k, v := range legacyAWG { + if conf[k] == v { + same = append(same, k) + } + } + sort.Strings(same) + if len(same) > 0 { + bad("VPN: общие для старых установок значения ("+strings.Join(same, ", ")+") — по ним DPI находит все такие серверы сразу", + "переустановка без сохранения данных (awg-ctrl reinstall) и раздача новых ключей") + } else { + ok("VPN: порт и параметры обфускации свои у этого сервера") + } + if conf["I1"] == "" { + bad("VPN: нет своего I1 — ключи получают общий пакет-маскировку", "awg-ctrl reinstall с сохранением данных выдаст свой — старые ключи продолжат работать") + } + } + } + if c.hasPanel() { + if env("UI_PREFIX", "") != "" { + ok("Панель: секретный путь — снаружи без него 404") + } else { + bad("Панель без секретного пути — сканер узнаёт её по странице логина", "awg-ctrl panel path new") + } + bad("Панель по голому HTTP: пути, JSON и токены видны DPI", "nginx с TLS перед панелью (README, «Панель за nginx») или вход по VPN") + if strings.ToLower(env("SUB_TLS", "off")) == "on" { + ok("Подписка sen:// по HTTPS") + } else { + bad("Подписка sen:// по HTTP: ответы подписаны, но видны DPI", "переустановка с SUB_TLS=on — ссылки придётся выдать заново") + } + if cn := certCN(c.svcEnv("ui", "SUB_TLS_CERT_FILE")); cn == "sen" { + bad("Сертификат подписки с общим именем «sen»", "имя меняется только с ключом — при переустановке без сохранения данных") + } + if env("NODE_PORT", "") != "" { + if cn := certCN(c.svcEnv("ui", "NODE_TLS_CERT_FILE")); cn == "awg-node" { + bad("Сертификат хаба с общим именем «awg-node»", "имя меняется только с ключом — ноды придётся подключить заново") + } + switch strings.ToLower(env("HUB_LEGACY", "")) { + case "on": + bad("Хаб пускает агентов без подписи (HUB_LEGACY=on)", "awg-ctrl hub legacy auto") + case "off": + ok("Хаб: только агенты с подписью") + default: + ok("Хаб: подпись до апгрейда; кто из нод ещё без неё — awg-ctrl hub") + } + } + } + fmt.Print("\n " + dim("Фаервол хостера (панель провайдера) отсюда не виден — открыты ли там лишние порты, проверьте сами.") + "\n\n") +} + +// panelMenu — пункт меню: адрес панели и смена секретного пути. +func (c *CLI) panelMenu() { + if !c.hasPanel() { + fmt.Println(" Панели на этой машине нет — это нода") + return + } + c.showPanel() + fmt.Println(" 1) Новый случайный путь 2) Свой путь 3) Без пути Enter — назад") + switch strings.TrimSpace(c.ask(" › ")) { + case "1": + c.setPanelPath("new") + case "2": + if p := strings.TrimSpace(c.ask(" Путь (8–64 символа A–Z a–z 0–9 _ -): ")); p != "" { + c.setPanelPath(p) + } + case "3": + if c.confirmYes(" Без пути сканер снова узнает панель по странице логина. Убрать путь?") { + c.setPanelPath("off") + } + } +} diff --git a/internal/cli/installer.go b/internal/cli/installer.go new file mode 100644 index 0000000..37e9d9e --- /dev/null +++ b/internal/cli/installer.go @@ -0,0 +1,118 @@ +package cli + +// Обновить, переустановить, удалить — из меню awg-ctrl и командами `awg-ctrl update|reinstall| +// uninstall`. Работу делает install.sh (ACTION=… — без его меню): CLI находит скрипт, кладёт во +// временный файл и передаёт ему управление (exec), чтобы диалог шёл в том же терминале. +// +// Скрипт — только из /tmp: install.sh перед заменой файлов убивает процессы, в командной строке +// которых есть путь установки (kill_related). Запущенный из /opt/awg-control/install.sh — или +// текстом через `bash -c` — он убил бы сам себя. + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +// Откуда брать свежий установщик; INSTALLER_URL в cli.env — своё зеркало. +const defaultInstallerURL = "https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh" + +var installerActions = map[string]struct{ title, what string }{ + "update": {"Обновить", "новая версия; настройки, пользователи и роль сохранятся"}, + "reinstall": {"Переустановить", "с нуля; данные можно будет сохранить"}, + "uninstall": {"Удалить", "полностью: сервисы, данные, правила фаервола; копию данных предложит сохранить"}, +} + +// fetchInstaller — текст install.sh: для обновления и переустановки — свежий из сети (иначе +// поставилась бы та же версия), для удаления — копия из установки (работает и без сети). +// Копия — запасной путь и для переустановки. +func (c *CLI) fetchInstaller(action string) ([]byte, string, error) { + local := filepath.Join(c.root, "install.sh") + readLocal := func() ([]byte, string, error) { + b, err := os.ReadFile(local) + return b, local, err + } + if action == "uninstall" { + if b, src, err := readLocal(); err == nil { + return b, src, nil + } + } + url := env("INSTALLER_URL", defaultInstallerURL) + cl := &http.Client{Timeout: 30 * time.Second} + resp, err := cl.Get(url) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == 200 { + b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err == nil && strings.HasPrefix(string(b), "#!") { + return b, url, nil + } + } + err = fmt.Errorf("%s: HTTP %d", url, resp.StatusCode) + } + if action == "reinstall" { + if b, src, lerr := readLocal(); lerr == nil { + fmt.Printf(" %s\n", dim("Установщик не скачался ("+err.Error()+") — берём копию из установки, версия та же")) + return b, src, nil + } + } + return nil, "", err +} + +// runInstaller — подтверждение и exec install.sh с ACTION. Возвращается только при отказе или +// ошибке: после exec процесс CLI заменён установщиком. +func (c *CLI) runInstaller(action string, confirm bool) { + a, ok := installerActions[action] + if !ok { + errorf(" Неизвестное действие: %s", action) + return + } + if os.Geteuid() != 0 { + errorf(" Нужен root: sudo awg-ctrl %s", action) + return + } + if confirm && !c.confirmYes(fmt.Sprintf(" %s — %s. Продолжить?", a.title, a.what)) { + fmt.Println(" Отменено") + return + } + script, src, err := c.fetchInstaller(action) + if err != nil { + errorf(" Не удалось получить установщик: %v", err) + errorf(" %s", dim("Запустите его вручную (README, «Установка») или задайте INSTALLER_URL в "+c.envFile)) + return + } + f, err := os.CreateTemp("", "awg-install-*.sh") + if err != nil { + errorf(" %v", err) + return + } + f.Write(script) + f.Close() + fmt.Printf(" %s\n\n", dim("Установщик: "+src)) + bash := "/bin/bash" + if p, err := exec.LookPath("bash"); err == nil { + bash = p + } + // Исходное окружение, без cli.env: иначе UI_PORT, SERVER_NAME и прочие сохранённые значения + // установщик принял бы за явно заданные параметры. + environ := append(withoutKey(c.origEnv, "ACTION"), "ACTION="+action) + if err := syscall.Exec(bash, []string{"bash", f.Name()}, environ); err != nil { + errorf(" Не удалось запустить установщик: %v", err) + } +} + +func withoutKey(environ []string, key string) []string { + out := make([]string, 0, len(environ)+1) + for _, kv := range environ { + if !strings.HasPrefix(kv, key+"=") { + out = append(out, kv) + } + } + return out +} diff --git a/internal/cli/migrate.go b/internal/cli/migrate.go index e86cbb3..1ac4fc5 100644 --- a/internal/cli/migrate.go +++ b/internal/cli/migrate.go @@ -127,7 +127,7 @@ func (c *CLI) writeExport(out, dbFile string) error { return err } err = migrate.Write(f, db, c.paths(), exportMeta(c.version, name, ip, envPort("UI_PORT"), envPort("SUB_PORT"), - strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT"))) + strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT"), env("UI_PREFIX", ""))) if cerr := f.Close(); err == nil { err = cerr } @@ -135,11 +135,12 @@ func (c *CLI) writeExport(out, dbFile string) error { } // exportMeta — meta.json архива (v и panel_id добавляет migrate.Write). -func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int) jsjson.Object { +func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int, uiPrefix string) jsjson.Object { return jsjson.Object{ {Key: "version", Val: version}, {Key: "created_at", Val: time.Now().Unix()}, {Key: "server_name", Val: name}, {Key: "server_ip", Val: ip}, {Key: "ui_port", Val: uiPort}, {Key: "sub_port", Val: subPort}, {Key: "sub_tls", Val: subTLS}, {Key: "node_port", Val: nodePort}, + {Key: "ui_prefix", Val: uiPrefix}, } } @@ -247,6 +248,9 @@ func (c *CLI) migrateImport(file string) { fmt.Println() fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и")) fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой.")) + if !migrate.ValidUIPrefix(b.Str("ui_prefix")) && env("UI_PREFIX", "") != "" { + fmt.Println(" " + dim("У старой панели не было секретного пути: её закладкам и внешнему API нужен путь этой — /"+env("UI_PREFIX", "")+"/")) + } } // ── cancel (старый сервер) ───────────────────────────────────────────────── diff --git a/internal/ctrl/awg.go b/internal/ctrl/awg.go index 5752bce..78a6a86 100644 --- a/internal/ctrl/awg.go +++ b/internal/ctrl/awg.go @@ -345,8 +345,10 @@ func (s *Server) syncPeers() error { func (s *Server) reloadAwgParams() string { p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) }) p.I1 = s.getCfg(cfgI1, p.I1) + mtu := ReadMTU(s.confPath()) s.cfgMu.Lock() s.c.Params = p + s.c.MTU = mtu s.c.Gen = p.Gen() s.c.Keepalive = keepaliveByGen[s.c.Gen] g := s.c.Gen diff --git a/internal/ctrl/connlog.go b/internal/ctrl/connlog.go new file mode 100644 index 0000000..cadba57 --- /dev/null +++ b/internal/ctrl/connlog.go @@ -0,0 +1,287 @@ +package ctrl + +// Журнал VPN-подключений: кто, когда подключился и отключился, сколько длилась сессия и сколько +// прошло трафика, и когда каждый пир был на связи последний раз (для «кто давно не подключался»). +// +// У WireGuard нет событий подключения — только время последнего handshake и счётчики байт в +// `awg show dump`. Раз в 30 с сверяем их с открытыми сессиями: +// - пир стал онлайн (handshake моложе 3 мин) — «подключился» со временем этого handshake; +// - пир ушёл из онлайна или с интерфейса — «отключился» со временем последней активности +// (последнего тика, когда менялись счётчики): handshake идёт раз в 2 мин, активность точнее. +// Открытые сессии восстанавливаются из базы, поэтому перезапуск awg-ctrl не плодит дублей. +// Внешний IP клиента не пишем: это уже слежка, а не журнал. + +import ( + "strconv" + "strings" + "sync" + "time" +) + +const ( + connlogEvery = 30 * time.Second + connlogKeepS = 90 * 24 * 3600 // столько дней храним события + seenEveryS = 60 // last_seen пишем не чаще раза в минуту на пира + connlogLimit = 500 +) + +type connSession struct { + since int64 + rx0, tx0 float64 // счётчики на начало сессии + lastRx, lastTx float64 + active int64 // последний тик, когда менялись счётчики + seenSaved int64 +} + +type peerIdent struct{ name, owner, ip string } + +type ConnLog struct { + s *Server + mu sync.Mutex + open map[string]*connSession + loaded bool + pruned int64 + peersFn func() (map[string]peerStat, bool) // подменяется в тестах +} + +func newConnLog(s *Server) *ConnLog { + l := &ConnLog{s: s, open: map[string]*connSession{}} + l.peersFn = func() (map[string]peerStat, bool) { + if !s.interfaceStatus().Up { + return nil, false // интерфейс лежит — это не «все отключились», а пауза + } + return s.getPeersData(), true + } + return l +} + +func (l *ConnLog) Start() { + go func() { + for { + l.tick(time.Now().Unix()) + time.Sleep(connlogEvery) + } + }() +} + +// idents — имя пользователя или владелец пира sen:// и внутренний IP по публичному ключу. Строки +// читаем до конца до любых других запросов: соединение с SQLite одно (см. metrics_test.go). +func (l *ConnLog) idents() map[string]peerIdent { + out := map[string]peerIdent{} + if rows, err := l.s.db.Query("SELECT pub_key, name, ip FROM users"); err == nil { + for rows.Next() { + var pub, name, ip string + if rows.Scan(&pub, &name, &ip) == nil { + out[pub] = peerIdent{name: name, ip: ip} + } + } + rows.Close() + } + if rows, err := l.s.db.Query("SELECT pub_key, owner, ip FROM peers"); err == nil { + for rows.Next() { + var pub, owner, ip string + if rows.Scan(&pub, &owner, &ip) == nil { + out[pub] = peerIdent{owner: owner, ip: ip} + } + } + rows.Close() + } + return out +} + +// load — открытые сессии (последнее событие пира — «подключился») после перезапуска. +func (l *ConnLog) load(now int64) { + type row struct { + pub string + at, rx, tx int64 + seen int64 + } + var open []row + rows, err := l.s.db.Query(`SELECT c.pub_key, c.at, COALESCE(c.rx, 0), COALESCE(c.tx, 0), COALESCE(s.at, c.at) + FROM conn_log c + JOIN (SELECT pub_key, MAX(id) AS id FROM conn_log GROUP BY pub_key) last ON last.id = c.id + LEFT JOIN peer_seen s ON s.pub_key = c.pub_key + WHERE c.event = 'connect'`) + if err != nil { + return + } + for rows.Next() { + var r row + if rows.Scan(&r.pub, &r.at, &r.rx, &r.tx, &r.seen) == nil { + open = append(open, r) + } + } + rows.Close() + for _, r := range open { + l.open[r.pub] = &connSession{since: r.at, rx0: float64(r.rx), tx0: float64(r.tx), + lastRx: float64(r.rx), lastTx: float64(r.tx), active: r.seen, seenSaved: r.seen} + } + l.loaded = true +} + +func (l *ConnLog) tick(now int64) { + stats, ok := l.peersFn() + if !ok { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if !l.loaded { + l.load(now) + } + who := l.idents() + write := func(event, pub string, at int64, dur *int64, rx, tx float64) { + id := who[pub] + var d any + if dur != nil { + d = *dur + } + l.s.db.Exec("INSERT INTO conn_log (at, event, pub_key, name, owner, ip, duration, rx, tx) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + at, event, pub, id.name, id.owner, id.ip, d, int64(rx), int64(tx)) + } + seen := func(pub string, at int64) { + l.s.db.Exec("INSERT INTO peer_seen (pub_key, at) VALUES (?, ?) ON CONFLICT(pub_key) DO UPDATE SET at = excluded.at", pub, at) + } + closeSession := func(pub string, sess *connSession) { + at := max(sess.active, sess.since) + dur := at - sess.since + // Счётчики сбрасываются при перезапуске интерфейса: тогда считаем от нуля. + rx, tx := sess.lastRx-sess.rx0, sess.lastTx-sess.tx0 + if rx < 0 { + rx = sess.lastRx + } + if tx < 0 { + tx = sess.lastTx + } + write("disconnect", pub, at, &dur, rx, tx) + seen(pub, at) + delete(l.open, pub) + } + + for pub, st := range stats { + sess := l.open[pub] + switch { + case st.Online && sess == nil: + at := int64(st.LastHandshake) + if at <= 0 || at > now { + at = now + } + write("connect", pub, at, nil, st.Rx, st.Tx) + l.open[pub] = &connSession{since: at, rx0: st.Rx, tx0: st.Tx, lastRx: st.Rx, lastTx: st.Tx, active: now, seenSaved: now} + seen(pub, now) + case st.Online: + if st.Rx != sess.lastRx || st.Tx != sess.lastTx { + sess.lastRx, sess.lastTx, sess.active = st.Rx, st.Tx, now + } + if now-sess.seenSaved >= seenEveryS { + seen(pub, sess.active) + sess.seenSaved = now + } + case sess != nil: + if hs := int64(st.LastHandshake); hs > sess.active && hs <= now { + sess.active = hs + } + if st.Rx > sess.lastRx || st.Tx > sess.lastTx { + sess.lastRx, sess.lastTx = st.Rx, st.Tx + } + closeSession(pub, sess) + } + } + // Пира сняли с интерфейса (удалили пользователя, перевыпустили ключ) посреди сессии. + for pub, sess := range l.open { + if _, ok := stats[pub]; !ok { + closeSession(pub, sess) + } + } + if now-l.pruned >= 3600 { + l.s.db.Exec("DELETE FROM conn_log WHERE at < ?", now-connlogKeepS) + l.pruned = now + } +} + +// list — GET /api/connlog: события (новые сверху) и «когда был на связи» по каждому пиру. +// +// ?limit=N (до 500), ?before= — следующая страница, ?q=<строка> — по имени, владельцу, IP. +func (l *ConnLog) list(limit, before int64, q string) (ob, error) { + if limit <= 0 || limit > connlogLimit { + limit = connlogLimit + } + where, args := []string{"1 = 1"}, []any{} + if before > 0 { + where, args = append(where, "id < ?"), append(args, before) + } + if q = strings.TrimSpace(q); q != "" { + like := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(q) + "%" + where = append(where, `(name LIKE ? ESCAPE '\' OR owner LIKE ? ESCAPE '\' OR ip LIKE ? ESCAPE '\')`) + args = append(args, like, like, like) + } + args = append(args, limit) + rows, err := l.s.db.Query("SELECT id, at, event, pub_key, name, owner, ip, duration, rx, tx FROM conn_log WHERE "+ + strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...) + if err != nil { + return nil, err + } + events := []any{} + for rows.Next() { + var id, at int64 + var event, pub, name, owner, ip string + var dur, rx, tx *int64 + if err := rows.Scan(&id, &at, &event, &pub, &name, &owner, &ip, &dur, &rx, &tx); err != nil { + rows.Close() + return nil, err + } + e := ob{{"id", id}, {"at", at}, {"event", event}, {"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip}} + if event == "disconnect" { + e = append(e, kv{"duration", deref(dur)}, kv{"rx", deref(rx)}, kv{"tx", deref(tx)}) + } + events = append(events, e) + } + rows.Close() + + // Все пиры сервера с последним временем на связи (null — с тех пор, как ведётся журнал, не было). + stats := map[string]peerStat{} + if st, ok := l.peersFn(); ok { + stats = st + } + peers := []any{} + rows, err = l.s.db.Query(`SELECT u.pub_key, u.name, '' AS owner, u.ip, s.at FROM users u LEFT JOIN peer_seen s ON s.pub_key = u.pub_key + UNION ALL + SELECT p.pub_key, '', p.owner, p.ip, s.at FROM peers p LEFT JOIN peer_seen s ON s.pub_key = p.pub_key`) + if err != nil { + return nil, err + } + for rows.Next() { + var pub, name, owner, ip string + var at *int64 + if err := rows.Scan(&pub, &name, &owner, &ip, &at); err != nil { + rows.Close() + return nil, err + } + var last any + if at != nil { + last = *at + } + peers = append(peers, ob{{"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip}, + {"online", stats[pub].Online}, {"last_seen", last}}) + } + rows.Close() + + var since any + var first int64 + if l.s.db.QueryRow("SELECT MIN(at) FROM conn_log").Scan(&first) == nil && first > 0 { + since = first + } + return ob{{"events", events}, {"peers", peers}, {"since", since}, {"keep_days", connlogKeepS / 86400}}, nil +} + +func deref(p *int64) any { + if p == nil { + return nil + } + return *p +} + +func queryInt(c *rctx, k string) int64 { + n, _ := strconv.ParseInt(c.r.URL.Query().Get(k), 10, 64) + return n +} diff --git a/internal/ctrl/connlog_test.go b/internal/ctrl/connlog_test.go new file mode 100644 index 0000000..4c507ef --- /dev/null +++ b/internal/ctrl/connlog_test.go @@ -0,0 +1,116 @@ +package ctrl + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/maeneko/forgetting/internal/jsjson" + "github.com/maeneko/forgetting/internal/logx" +) + +func TestConnLog(t *testing.T) { + db, err := openDB(filepath.Join(t.TempDir(), "users.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES ('ivan', '10.9.0.2', 'PK_IVAN', 'vpn://x')") + db.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES ('PK_PHONE', '10.9.0.3', 'psk', 'p1/m1/d2', 1)") + s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())} + + stats := map[string]peerStat{} + up := true + newLog := func() *ConnLog { + l := newConnLog(s) + l.peersFn = func() (map[string]peerStat, bool) { + cp := map[string]peerStat{} + for k, v := range stats { + cp[k] = v + } + return cp, up + } + return l + } + l := newLog() + peer := func(pub string, online bool, hs, rx, tx float64) { + stats[pub] = peerStat{Online: online, LastHandshake: hs, Rx: rx, Tx: tx} + } + events := func() string { + v, err := l.list(0, 0, "") + if err != nil { + t.Fatal(err) + } + var b []string + for _, e := range v.Get2("events").([]any) { + o := e.(jsjson.Object) + s := o.Get2("event").(string) + " " + o.Get2("name").(string) + o.Get2("owner").(string) + " @" + string(jsjson.MustMarshal(o.Get2("at"))) + if o.Get2("event") == "disconnect" { + s += " dur=" + string(jsjson.MustMarshal(o.Get2("duration"))) + " rx=" + string(jsjson.MustMarshal(o.Get2("rx"))) + } + b = append([]string{s}, b...) // старые сверху — читать проще + } + return strings.Join(b, "; ") + } + + peer("PK_IVAN", false, 0, 0, 0) + peer("PK_PHONE", false, 0, 0, 0) + l.tick(1000) + if got := events(); got != "" { + t.Fatalf("без онлайна событий быть не должно: %s", got) + } + + peer("PK_IVAN", true, 1010, 100, 50) // подключился, handshake в 1010 + l.tick(1030) + peer("PK_IVAN", true, 1010, 900, 300) // качает + l.tick(1060) + peer("PK_IVAN", true, 1130, 900, 300) // keepalive-handshake без трафика + l.tick(1150) + up = false // интерфейс лёг — это не отключение + l.tick(1180) + up = true + peer("PK_IVAN", false, 1130, 900, 300) + l.tick(1330) + if got, want := events(), "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800"; got != want { + t.Fatalf("сессия:\n got %s\nwant %s", got, want) + } + + // Перезапуск awg-ctrl посреди сессии: новый ConnLog поднимает открытую сессию из базы. + peer("PK_PHONE", true, 2000, 10, 10) + l.tick(2010) + l = newLog() + peer("PK_PHONE", true, 2100, 70, 10) + l.tick(2110) + // Пира сняли с интерфейса (удалили устройство) — сессия закрывается. + delete(stats, "PK_PHONE") + l.tick(2140) + want := "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800; connect p1/m1/d2 @2000; disconnect p1/m1/d2 @2110 dur=110 rx=60" + if got := events(); got != want { + t.Fatalf("после перезапуска:\n got %s\nwant %s", got, want) + } + + // Счётчики сбросились (перезапуск интерфейса) — трафик сессии считается с нуля, не уходит в минус. + peer("PK_IVAN", true, 3000, 5000, 5000) + l.tick(3010) + peer("PK_IVAN", true, 3100, 40, 40) + l.tick(3110) + peer("PK_IVAN", false, 3100, 40, 40) + l.tick(3400) + if got := events(); !strings.HasSuffix(got, "connect ivan @3000; disconnect ivan @3110 dur=110 rx=40") { + t.Fatalf("сброс счётчиков: %s", got) + } + + v, _ := l.list(0, 0, "") + seen := map[string]any{} + for _, p := range v.Get2("peers").([]any) { + o := p.(jsjson.Object) + seen[o.Get2("pub_key").(string)] = o.Get2("last_seen") + } + if seen["PK_IVAN"] != float64(3110) && seen["PK_IVAN"] != int64(3110) { + t.Errorf("last_seen ivan: %v", seen["PK_IVAN"]) + } + if f, _ := l.list(0, 0, "ivan"); len(f.Get2("events").([]any)) != 4 { + t.Errorf("поиск по имени: %d событий", len(f.Get2("events").([]any))) + } +} diff --git a/internal/ctrl/db.go b/internal/ctrl/db.go index 064364a..5860fae 100644 --- a/internal/ctrl/db.go +++ b/internal/ctrl/db.go @@ -100,6 +100,26 @@ func initSchema(db *sql.DB) error { psk_key TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL + )`, + // Журнал VPN-подключений (connlog.go). name/owner/ip — на момент события: пользователя + // могут удалить, а запись о нём остаётся. + `CREATE TABLE IF NOT EXISTS conn_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at INTEGER NOT NULL, + event TEXT NOT NULL, + pub_key TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + duration INTEGER, + rx INTEGER, + tx INTEGER + )`, + "CREATE INDEX IF NOT EXISTS idx_conn_log_at ON conn_log (at)", + "CREATE INDEX IF NOT EXISTS idx_conn_log_pub ON conn_log (pub_key, id)", + `CREATE TABLE IF NOT EXISTS peer_seen ( + pub_key TEXT PRIMARY KEY, + at INTEGER NOT NULL )`, } { if _, err := db.Exec(q); err != nil { diff --git a/internal/ctrl/params.go b/internal/ctrl/params.go index cb9d967..a88f459 100644 --- a/internal/ctrl/params.go +++ b/internal/ctrl/params.go @@ -70,6 +70,28 @@ var AWG31Defaults = [][2]string{ {"MaxHandshakeAttempts", "15-20"}, } +// DefaultMTU — MTU установок до рандомизации; запасное, если в awg1.conf его нет или оно странное. +const DefaultMTU = 1376 + +// ReadMTU читает MTU из [Interface] awg1.conf: install.sh выбирает его случайно, у каждого +// сервера своё, и клиенты получают его в vpn:// ключе. +func ReadMTU(confFile string) int { + raw, err := os.ReadFile(confFile) + if err != nil { + return DefaultMTU + } + iface := pPeerSplit.Split(string(raw), 2)[0] + m := regexp.MustCompile(`(?m)^\s*MTU\s*=\s*(\d+)`).FindStringSubmatch(iface) + if m == nil { + return DefaultMTU + } + n, _ := strconv.Atoi(m[1]) + if n < 1280 || n > 1500 { + return DefaultMTU + } + return n +} + // При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12. const minSForHeaderProtection = 12 diff --git a/internal/ctrl/routes.go b/internal/ctrl/routes.go index ffd56f2..4beeac8 100644 --- a/internal/ctrl/routes.go +++ b/internal/ctrl/routes.go @@ -37,6 +37,14 @@ func (s *Server) routes() []route { {"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil }), + R("GET", "/api/connlog", false, a, func(c *rctx) (result, error) { + v, err := s.connlog.list(queryInt(c, "limit"), queryInt(c, "before"), c.r.URL.Query().Get("q")) + if err != nil { + return result{}, err + } + return ok(v) + }), + R("POST", "/api/users", true, an, func(c *rctx) (result, error) { name, _ := c.obj().Get2("name").(string) if u, err := s.userGet(name); err != nil { diff --git a/internal/ctrl/server.go b/internal/ctrl/server.go index e68c0fa..89208a2 100644 --- a/internal/ctrl/server.go +++ b/internal/ctrl/server.go @@ -56,6 +56,7 @@ type Server struct { shaping *Shaping torrent *Torrent + connlog *ConnLog metrics metricsCache monitor monitorJob cliPath string @@ -151,13 +152,14 @@ func New() (*Server, error) { module, tools := readVersions() s.c = Config{ ServerIP: ip, ServerPort: sp, ServerName: name, - DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: 1376, Subnet: "10.9", + DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: ReadMTU(s.confPath()), Subnet: "10.9", Keepalive: keepaliveByGen[gen], Params: params, Gen: gen, AwgModule: module, AwgTools: tools, } s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen) s.shaping = newShaping(s) s.torrent = newTorrent(s) + s.connlog = newConnLog(s) return s, nil } @@ -186,5 +188,6 @@ func (s *Server) Run() error { go s.shaping.Apply() s.shaping.Start() go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново + s.connlog.Start() return <-errc } diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index 6948819..bbefa7c 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -22,6 +22,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" "time" @@ -243,6 +244,11 @@ type Plan struct { Env [][2]string } +var reUIPrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + +// ValidUIPrefix — годится ли строка в секретный префикс панели (UI_PREFIX). +func ValidUIPrefix(s string) bool { return reUIPrefix.MatchString(s) } + // NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес. func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) { p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host} @@ -270,6 +276,11 @@ func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn}) // С этого момента панель считает, кто из клиентов и нод узнал её новый адрес. p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)}) + // Секретный путь старой панели: по нему ходят её закладки и внешний API через ретранслятор. + // Свой префикс новая панель не меняет (иначе потеряла бы открытую вкладку) — принимает оба. + if px := b.Str("ui_prefix"); ValidUIPrefix(px) { + p.Env = append(p.Env, [2]string{"UI_PREFIX_MOVED", px}) + } // Заголовку PROXY от ретранслятора верим только с адреса старого сервера. if ip := b.Str("server_ip"); ip != "" { p.Env = append(p.Env, [2]string{"RELAY_FROM", ip}) diff --git a/internal/ui/connlog.go b/internal/ui/connlog.go new file mode 100644 index 0000000..3f65f17 --- /dev/null +++ b/internal/ui/connlog.go @@ -0,0 +1,78 @@ +package ui + +// Журнал VPN-подключений (вкладка «Подключения»): события ведёт awg-ctrl каждого сервера +// (internal/ctrl/connlog.go), панель забирает их у выбранного сервера и подставляет имена, которых +// awg-ctrl не знает: пир sen:// там — только владелец «<панель>/m<ключ>/d<устройство>». + +import ( + "net/url" + "regexp" + "strconv" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`) + +// ownerNames — название мастер-ключа и имя устройства для владельца пира. foreign — пир другой +// панели на том же awg-ctrl (у ноды может быть своя): её имён мы не знаем. +func (s *Sub) ownerNames(owner string, cache map[string][3]any) (key, device any, foreign bool) { + if v, ok := cache[owner]; ok { + return v[0], v[1], v[2].(bool) + } + m := reOwner.FindStringSubmatch(owner) + switch { + case m == nil, m[1] != "" && m[1] != s.panelID: // не наш формат или чужая панель + foreign = true + default: + mid, _ := strconv.ParseInt(m[2], 10, 64) + did, _ := strconv.ParseInt(m[3], 10, 64) + var label, name string + if s.a.db.QueryRow("SELECT label FROM master_keys WHERE id = ?", mid).Scan(&label) == nil { + key = label + } + if s.a.db.QueryRow("SELECT device_name FROM devices WHERE id = ?", did).Scan(&name) == nil { + device = name + } + } + cache[owner] = [3]any{key, device, foreign} + return key, device, foreign +} + +func (a *App) connlog(c *Ctx) bool { + q := url.Values{} + for _, k := range []string{"limit", "before", "q"} { + if v := c.R.URL.Query().Get(k); v != "" { + q.Set(k, v) + } + } + r, err := a.nodes.CtrlFor(a.serverIDOf(c))("GET", "/api/connlog?"+q.Encode(), nil) + if err != nil { + return c.Error(502, "awg-ctrl недоступен") + } + if r.Status == 404 { + return c.Error(501, "awg-ctrl на этом сервере старой версии и журнала не ведёт — обновите сервер") + } + data, ok := r.Data.(jsjson.Object) + if r.Status != 200 || !ok { + return c.JSON(r.Status, r.Data) + } + cache := map[string][3]any{} + enrich := func(list any) { + items, _ := list.([]any) + for i, it := range items { + o, ok := it.(jsjson.Object) + if !ok { + continue + } + if owner, _ := o.Get2("owner").(string); owner != "" && a.sub != nil { + key, dev, foreign := a.sub.ownerNames(owner, cache) + o = append(o, jsjson.KV{Key: "key", Val: key}, jsjson.KV{Key: "device", Val: dev}, jsjson.KV{Key: "foreign", Val: foreign}) + } + items[i] = o + } + } + enrich(data.Get2("events")) + enrich(data.Get2("peers")) + return c.JSON(200, data) +} diff --git a/internal/ui/connlog_test.go b/internal/ui/connlog_test.go new file mode 100644 index 0000000..fc4acdc --- /dev/null +++ b/internal/ui/connlog_test.go @@ -0,0 +1,30 @@ +package ui + +import "testing" + +// Владелец пира sen:// в журнале → название мастер-ключа и устройства этой панели. +func TestConnLogOwnerNames(t *testing.T) { + a := testApp(t, t.TempDir(), "on") + s := &Sub{a: a} + if err := s.initSchema(); err != nil { + t.Fatal(err) + } + s.panelID = "abcd1234" // initSchema заводит свой + + a.db.Exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)") + a.db.Exec("INSERT INTO devices (id, master_id, device_id, device_name, pub_key, auth_pub, created_at) VALUES (2, 1, 'd', 'iPhone Маши', 'W', 'A', 1)") + cache := map[string][3]any{} + for owner, want := range map[string][3]any{ + "abcd1234/m1/d2": {"Семья", "iPhone Маши", false}, + "m1/d2": {"Семья", "iPhone Маши", false}, // выдан до областей панели + "abcd1234/m1/d9": {"Семья", nil, false}, // устройство удалено + "abcd1234/m7/d9": {nil, nil, false}, // и ключ удалён + "ffff0000/m1/d2": {nil, nil, true}, // другая панель на том же awg-ctrl + "что-то/другое": {nil, nil, true}, + } { + k, d, f := s.ownerNames(owner, cache) + if k != want[0] || d != want[1] || f != want[2] { + t.Errorf("%s: %v %v %v, ждали %v", owner, k, d, f, want) + } + } +} diff --git a/internal/ui/hub_preauth.go b/internal/ui/hub_preauth.go new file mode 100644 index 0000000..ce3cf4c --- /dev/null +++ b/internal/ui/hub_preauth.go @@ -0,0 +1,89 @@ +package ui + +// Подпись агента ДО апгрейда на WebSocket (stealth хаба нод). Раньше хаб принимал WebSocket у +// любого и сразу слал hello с nonce: путь /node/v1 есть в открытом коде, и сканер, знающий +// протокол, узнавал хаб одним подключением. Теперь агент кладёт подпись своим ключом в заголовки +// запроса, хаб проверяет её до Accept и без неё отвечает 404 пустого Go-сервера. +// +// Переходный режим: агенты до этой версии заголовков не шлют. Их пускают по-старому, пока в +// панели есть нода, ни разу не подключавшаяся с подписью (nodes.preauth = 0): обновили все — +// путь без подписи закрывается сам. HUB_LEGACY=on / off — принудительно. +// +// Внутри WebSocket протокол прежний (hello → auth → ready): кадр auth обязан быть от той же ноды. + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/subtle" + "net/http" + "os" + "strconv" + "strings" +) + +// Часы ноды могут расходиться с панелью: окно широкое. Повтор подписи не страшен — заголовки +// идут внутри TLS с закреплённым сертификатом, подсмотреть их нельзя. +const preauthWindow = 24 * 3600 + +// preauthMsg — что агент подписывает в заголовке X-Node-Sig. Дублируется в internal/agent. +func preauthMsg(nodeID, ts int64) []byte { + return []byte("awg-node-pre-v1\n" + strconv.FormatInt(nodeID, 10) + "\n" + strconv.FormatInt(ts, 10)) +} + +// joinOK — одноразовый секрет первой привязки ноды верен и не просрочен. +func joinOK(row *nodeRow, secret string) bool { + if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || secret == "" { + return false + } + h := sha256.Sum256(fromB64url(secret)) + return len(row.JoinHash) == len(h) && subtle.ConstantTimeCompare(h[:], row.JoinHash) == 1 +} + +// preauth — подпись из заголовков. present — агент её прислал (новая версия); row — нода, если +// подпись верна. +func (n *Nodes) preauth(r *http.Request) (row *nodeRow, present bool) { + idS, tsS, sig := r.Header.Get("X-Node-Id"), r.Header.Get("X-Node-Ts"), r.Header.Get("X-Node-Sig") + if idS == "" && tsS == "" && sig == "" { + return nil, false + } + id, err1 := strconv.ParseInt(idS, 10, 64) + ts, err2 := strconv.ParseInt(tsS, 10, 64) + if err1 != nil || err2 != nil || sig == "" { + return nil, true + } + if d := ts - now(); d > preauthWindow || d < -preauthWindow { + warnf("nodes: подпись ноды #%d вне окна: часы расходятся на %d с", id, d) + return nil, true + } + row = n.row(id) + if row == nil { + return nil, true + } + pub := row.Pub.String + if !row.Pub.Valid { + // Первое подключение: ключа ноды ещё нет, его приносит агент вместе с секретом привязки. + if !joinOK(row, r.Header.Get("X-Node-Join")) { + return nil, true + } + pub = r.Header.Get("X-Node-Pub") + } + key := fromB64url(pub) + if len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, preauthMsg(id, ts), fromB64url(sig)) { + return nil, true + } + return row, true +} + +// legacyOpen — пускать ли подключение без подписи (агент старой версии). +func (n *Nodes) legacyOpen() bool { + switch strings.ToLower(os.Getenv("HUB_LEGACY")) { + case "on": + return true + case "off": + return false + } + var waiting int + n.a.db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE preauth = 0 + AND (pub IS NOT NULL OR (join_hash IS NOT NULL AND join_expires >= ?))`, now()).Scan(&waiting) + return waiting > 0 +} diff --git a/internal/ui/hub_preauth_test.go b/internal/ui/hub_preauth_test.go new file mode 100644 index 0000000..ce5f50f --- /dev/null +++ b/internal/ui/hub_preauth_test.go @@ -0,0 +1,224 @@ +package ui + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/maeneko/forgetting/internal/agent" + "github.com/maeneko/forgetting/internal/jsjson" +) + +// Хаб нод: WebSocket — только агенту с подписью в заголовках; остальным 404 пустого сервера. +// Старых агентов (без подписи) пускают, пока не все ноды подключались по-новому. +func TestHubPreauth(t *testing.T) { + p := newMovePanel(t, "on", "") + pub3, priv3, _ := ed25519.GenerateKey(rand.Reader) + _, stranger, _ := ed25519.GenerateKey(rand.Reader) + if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', ?, 1)", b64url.EncodeToString(pub3)); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + hdr := func(id int64, key ed25519.PrivateKey, ts int64) http.Header { + h := http.Header{} + h.Set("X-Node-Id", strconv.FormatInt(id, 10)) + h.Set("X-Node-Ts", strconv.FormatInt(ts, 10)) + h.Set("X-Node-Sig", b64url.EncodeToString(ed25519.Sign(key, preauthMsg(id, ts)))) + return h + } + // dial — код ответа на апгрейд (101 — WebSocket принят) и первый кадр. + dial := func(h http.Header) (int, *websocket.Conn) { + ws, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h}) + if err != nil { + if resp == nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 404 && string(body) != "404 page not found\n" { + t.Errorf("тело 404 не как у пустого сервера: %q", body) + } + return resp.StatusCode, nil + } + return 101, ws + } + read := func(ws *websocket.Conn) jsjson.Object { + _, raw, err := ws.Read(ctx) + if err != nil { + return nil + } + v, _ := jsjson.Decode(raw) + o, _ := v.(jsjson.Object) + return o + } + auth := func(ws *websocket.Conn, id int64, key ed25519.PrivateKey) jsjson.Object { + nonce := read(ws).Get2("nonce").(string) + ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: id}, + {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, authMsg(nonce, id)))}, {Key: "version", Val: MinNodeVersion}})) + return read(ws) + } + now := time.Now().Unix() + + if code, _ := dial(hdr(3, stranger, now)); code != 404 { + t.Errorf("чужая подпись: %d, ждали 404", code) + } + if code, _ := dial(hdr(99, stranger, now)); code != 404 { + t.Errorf("несуществующая нода: %d, ждали 404", code) + } + if code, _ := dial(hdr(3, priv3, now-2*preauthWindow)); code != 404 { + t.Errorf("подпись вне окна: %d, ждали 404", code) + } + + // Переходный режим: нода 3 ещё не подключалась с подписью — старый агент проходит. + code, ws := dial(nil) + if code != 101 { + t.Fatalf("старый агент в переходном режиме: %d", code) + } + if r := auth(ws, 3, priv3); r.Get2("t") != "ready" { + t.Fatalf("старый агент: %v", r) + } + ws.CloseNow() + + // Новый агент: подпись в заголовках, затем прежний auth внутри. + code, ws = dial(hdr(3, priv3, now)) + if code != 101 { + t.Fatalf("новый агент: %d", code) + } + if r := auth(ws, 3, priv3); r.Get2("t") != "ready" { + t.Fatalf("новый агент: %v", r) + } + ws.CloseNow() + + // Все ноды подключались с подписью — без неё больше не пускают. + if code, _ := dial(nil); code != 404 { + t.Errorf("без подписи после перехода: %d, ждали 404", code) + } + resp, err := http.Get(srv.URL + "/node/v1") + if err != nil || resp.StatusCode != 404 { + t.Errorf("обычный GET: %v %v", resp, err) + } + t.Setenv("HUB_LEGACY", "on") + if code, _ := dial(nil); code != 101 { + t.Errorf("HUB_LEGACY=on: %d", code) + } + t.Setenv("HUB_LEGACY", "") + + // Подпись одной ноды, auth от имени другой — отказ. + pub5, priv5, _ := ed25519.GenerateKey(rand.Reader) + p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at, preauth) VALUES (5, 'Riga', ?, 1, 1)", b64url.EncodeToString(pub5)) + code, ws = dial(hdr(3, priv3, now+1)) + if code != 101 { + t.Fatalf("нода 3: %d", code) + } + if r := auth(ws, 5, priv5); r != nil { + t.Errorf("auth чужой ноды после подписи ноды 3 принят: %v", r) + } + ws.CloseNow() +} + +// Настоящий агент привязывается к настоящему хабу по строке join и подключается снова уже +// своим ключом — в обоих случаях с подписью до апгрейда. +func TestHubAgentEndToEnd(t *testing.T) { + t.Setenv("HUB_LEGACY", "off") + p := newMovePanel(t, "on", "") + secretRaw := make([]byte, 16) + rand.Read(secretRaw) + secret := b64url.EncodeToString(secretRaw) + h := sha256.Sum256(secretRaw) + if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (7, 'Новая', ?, ?, 1)", + h[:], time.Now().Unix()+600); err != nil { + t.Fatal(err) + } + srv := httptest.NewTLSServer(http.HandlerFunc(p.a.nodes.serveWS)) + defer srv.Close() + u, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(u.Port()) + pin := certPin(t, srv.Certificate().Raw) + + dir := t.TempDir() + _, ik, _ := ed25519.GenerateKey(rand.Reader) + der, _ := x509.MarshalPKCS8PrivateKey(ik) + os.WriteFile(filepath.Join(dir, "internal.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600) + env := filepath.Join(dir, "cli.env") + os.WriteFile(env, []byte("JOIN_SECRET="+secret+"\nNODE_ID=7\n"), 0o600) + run := func() { + a, err := agent.New(agent.Config{ + CoreHost: u.Hostname(), CorePort: port, CorePin: pin, NodeID: 7, Ctrl: "http://127.0.0.1:1", + NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"), + CLIEnvFile: env, JoinSecret: envValue(env, "JOIN_SECRET"), Version: MinNodeVersion, + Logf: t.Logf, Errorf: t.Logf, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.Run(ctx) + for range 100 { + if p.a.nodes.IsOnline(7) { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("агент не подключился") + } + + run() // привязка по секрету + row := p.a.nodes.row(7) + if !row.Pub.Valid { + t.Fatal("ключ ноды не сохранён после привязки") + } + var pre int + p.a.db.QueryRow("SELECT preauth FROM nodes WHERE id = 7").Scan(&pre) + if pre != 1 { + t.Error("подключение с подписью не отмечено (preauth)") + } + for range 100 { // прежнее соединение снимается при отмене контекста + if !p.a.nodes.IsOnline(7) { + break + } + time.Sleep(20 * time.Millisecond) + } + if strings.Contains(envValue(env, "JOIN_SECRET"), secret) { + t.Error("секрет привязки не стёрт после первого подключения") + } + run() // повторное подключение — уже своим ключом +} + +func certPin(t *testing.T, raw []byte) string { + cert, err := x509.ParseCertificate(raw) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + return b64url.EncodeToString(sum[:]) +} + +func envValue(file, key string) string { + raw, _ := os.ReadFile(file) + for _, l := range strings.Split(string(raw), "\n") { + if v, ok := strings.CutPrefix(l, key+"="); ok { + return v + } + } + return "" +} diff --git a/internal/ui/migrate.go b/internal/ui/migrate.go index c033987..77834a7 100644 --- a/internal/ui/migrate.go +++ b/internal/ui/migrate.go @@ -211,6 +211,18 @@ func (m *Migration) newCode(c *Ctx) bool { return m.status(c) } +// awaitsImport — запрос на приём архива, пока выдан код: старая панель шлёт его на +// /migrate/v1/import без секретного префикса (в коде переноса его нет), prefixGate пропускает. +// Без кода путь снаружи не виден, как и остальные. +func (m *Migration) awaitsImport(r *http.Request) bool { + if r.Method != "POST" || r.URL.Path != "/migrate/v1/import" { + return false + } + m.mu.Lock() + defer m.mu.Unlock() + return m.key != nil && time.Now().Before(m.expires) +} + // importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM). // Ответ с ошибкой — открытым текстом и только пока ничего не тронуто. func (m *Migration) importHandler(c *Ctx) bool { @@ -420,7 +432,7 @@ func (m *Migration) exportMeta() jsjson.Object { {Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()}, {Key: "server_name", Val: name}, {Key: "server_ip", Val: ip}, {Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS}, - {Key: "node_port", Val: m.a.nodes.port}, + {Key: "node_port", Val: m.a.nodes.port}, {Key: "ui_prefix", Val: os.Getenv("UI_PREFIX")}, } } diff --git a/internal/ui/nodes.go b/internal/ui/nodes.go index 037ebdd..8172393 100644 --- a/internal/ui/nodes.go +++ b/internal/ui/nodes.go @@ -15,7 +15,6 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" - "crypto/subtle" "crypto/tls" "database/sql" "fmt" @@ -153,17 +152,15 @@ func newNodes(a *App) (*Nodes, error) { } return filepath.Join(a.baseDir, name) } - // TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же, + // TLS: самоподписанный сертификат, pin которого зашит в join-строки. Имя в нём случайное: + // одинаковое у всех серверов было бы сигнатурой для сканера (pin — по ключу, не по имени). Живёт столько же, // сколько core: пересоздать = переподключить все ноды. if n.port != 0 { keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt") if err := func() error { if !exists(keyF) && !exists(crtF) { - // CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI. - cn := make([]byte, 6) - rand.Read(cn) cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", - "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", fmt.Sprintf("/CN=%x", cn)) + "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN="+randomCN()) if err := cmd.Run(); err != nil { return err } @@ -218,6 +215,13 @@ func newNodes(a *App) (*Nodes, error) { return nil, err } } + // preauth — нода хоть раз подключилась с подписью в заголовках (hub_preauth.go): пока такие + // есть не все, хаб пускает и старых агентов без подписи. + if !hasColumn(a.db, "nodes", "preauth") { + if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN preauth INTEGER NOT NULL DEFAULT 0"); err != nil { + return nil, err + } + } // Нода №0 и локальный сервер — один и тот же id: вместе их не бывает. if n.local && n.row(0) != nil { return nil, fmt.Errorf("nodes: в ui.db есть нода #0 (сервер, с которого переехала панель), а LOCAL_NODE=on — " + @@ -484,6 +488,13 @@ const closeOutdated = 4026 // cmpVersion сравнивает версии вида 0.5.0 (хвост после «-» не учитывается): <0 — a старше b. func cmpVersion(a, b string) int { + // «Beta 0.5.0» → «0.5.0»: канал перед номером не сравниваем. + if f := strings.Fields(a); len(f) > 0 { + a = f[len(f)-1] + } + if f := strings.Fields(b); len(f) > 0 { + b = f[len(f)-1] + } pa, pb := strings.Split(strings.SplitN(a, "-", 2)[0], "."), strings.Split(strings.SplitN(b, "-", 2)[0], ".") for i := 0; i < max(len(pa), len(pb)); i++ { var x, y int @@ -506,15 +517,27 @@ func closeWS(ws *websocket.Conn, code int, reason string) { func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { ip, _, _ := net.SplitHostPort(r.RemoteAddr) + // До апгрейда: без верной подписи ноды (или старого агента в переходном режиме) — 404, как + // у пустого сервера; WebSocket и hello видит только своя нода (hub_preauth.go). + if n.blocked(ip) { + http.NotFound(w, r) + return + } + pre, present := n.preauth(r) + if present && pre == nil { + n.failed(ip) + http.NotFound(w, r) + return + } + if !present && !n.legacyOpen() { + http.NotFound(w, r) + return + } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled}) if err != nil { return } ws.SetReadLimit(hubMaxFrame) - if n.blocked(ip) { - ws.Close(4029, "rate_limited") - return - } nb := make([]byte, 16) rand.Read(nb) nonce := b64url.EncodeToString(nb) @@ -532,21 +555,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { return } row := n.row(int64(m.Get2("node_id").(float64))) - if row == nil { + if row == nil || pre != nil && pre.ID != row.ID { deny() return } pubB64 := row.Pub.String joining := !row.Pub.Valid if joining { - secret, sOK := field(m, "join", "secret").(string) + secret, _ := field(m, "join", "secret").(string) pub, pOK := field(m, "join", "pub").(string) - if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || !sOK || !pOK { - deny() - return - } - h := sha256.Sum256(fromB64url(secret)) - if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 { + if !pOK || !joinOK(row, secret) { deny() return } @@ -574,6 +592,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { closeWS(ws, closeOutdated, "outdated "+MinNodeVersion) return } + if pre != nil { + n.a.db.Exec("UPDATE nodes SET preauth = 1 WHERE id = ?", row.ID) + } conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}} nodeID = row.ID @@ -594,6 +615,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { if joining { first = " (первое подключение)" } + if pre == nil { + first += " — без подписи до апгрейда: старый агент, обновите ноду, чтобы хаб скрылся от сканеров" + } logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first) go n.refreshHealth(row.ID) go n.fireOnline(int(row.ID)) @@ -963,3 +987,15 @@ func (n *Nodes) router() *Router { }) return rt } + +// randomCN — имя для самоподписанного сертификата: похожее на обычный хост и своё у каждого +// сервера. +func randomCN() string { + const abc = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 10) + rand.Read(b) + for i := range b { + b[i] = abc[int(b[i])%len(abc)] + } + return string(b) + ".net" +} diff --git a/internal/ui/outdated_test.go b/internal/ui/outdated_test.go index db5a030..18af46d 100644 --- a/internal/ui/outdated_test.go +++ b/internal/ui/outdated_test.go @@ -79,7 +79,7 @@ func TestCmpVersion(t *testing.T) { for _, c := range []struct { a, b string sign int - }{{"0.5.0", "0.5.0", 0}, {"0.4.9", "0.5.0", -1}, {"0.10.0", "0.9.9", 1}, {"0.5", "0.5.0", 0}, {"0.5.1-beta", "0.5.1", 0}} { + }{{"0.5.0", "0.5.0", 0}, {"0.4.9", "0.5.0", -1}, {"0.10.0", "0.9.9", 1}, {"0.5", "0.5.0", 0}, {"0.5.1-beta", "0.5.1", 0}, {"Beta 0.4.7", "0.5.0", -1}} { got := cmpVersion(c.a, c.b) if (got > 0) != (c.sign > 0) || (got < 0) != (c.sign < 0) { t.Errorf("cmpVersion(%q, %q) = %d", c.a, c.b, got) diff --git a/internal/ui/prefix.go b/internal/ui/prefix.go new file mode 100644 index 0000000..6f682ae --- /dev/null +++ b/internal/ui/prefix.go @@ -0,0 +1,81 @@ +package ui + +// Секретный префикс панели (UI_PREFIX): снаружи панель отвечает только на /<префикс>/…, на всё +// остальное — как пустой Go-сервер (`404 page not found`). Порт панели открыт в интернет, и без +// префикса сканер узнавал её по странице логина и JS-бандлу — одним GET. +// +// UI_PREFIX_MOVED — префикс панели, переехавшей сюда (`migrate`): её закладки и внешний API +// приходят через ретранслятор старого сервера со своим путём. Принимаются оба. +// +// С loopback префикс не нужен: CLI, Prometheus этой машины (127.0.0.1:PORT/metrics) и nginx +// на этой же машине ходят по-старому (nginx в подпапке и есть свой секретный путь). Адрес — +// соединения, не X-Forwarded-For: заголовок подделывается. + +import ( + "net" + "net/http" + "os" + "strings" + + "github.com/maeneko/forgetting/internal/migrate" +) + +// uiPrefixes — UI_PREFIX и UI_PREFIX_MOVED из окружения; пустые и кривые пропускаются. +func uiPrefixes() []string { + var out []string + for _, k := range []string{"UI_PREFIX", "UI_PREFIX_MOVED"} { + p := strings.Trim(strings.TrimSpace(os.Getenv(k)), "/") + switch { + case p == "": + case !migrate.ValidUIPrefix(p): + warnf("ui: %s «%s» не подходит (8–64 символа A–Z a–z 0–9 _ -) — пропущен", k, p) + default: + out = append(out, p) + } + } + return out +} + +func isLoopback(remoteAddr string) bool { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// prefixGate пропускает в панель только запросы под префиксом и срезает его: дальше роутер +// видит те же пути, что без префикса. allow — исключения вне префикса (приём переноса). +func prefixGate(prefixes []string, next http.Handler, allow func(*http.Request) bool) http.Handler { + if len(prefixes) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + for _, px := range prefixes { + root := "/" + px + if p == root { + // Относительный адрес, как у static: за прокси в подпапке абсолютный увёл бы из неё. + w.Header().Set("Location", px+"/") + w.WriteHeader(http.StatusMovedPermanently) + return + } + if strings.HasPrefix(p, root+"/") { + r2 := r.Clone(r.Context()) + r2.URL.Path = p[len(root):] + if r.URL.RawPath != "" { + r2.URL.RawPath = strings.TrimPrefix(r.URL.RawPath, root) + } + r2.RequestURI = r2.URL.RequestURI() + next.ServeHTTP(w, r2) + return + } + } + if isLoopback(r.RemoteAddr) || allow != nil && allow(r) { + next.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + }) +} diff --git a/internal/ui/prefix_test.go b/internal/ui/prefix_test.go new file mode 100644 index 0000000..1506691 --- /dev/null +++ b/internal/ui/prefix_test.go @@ -0,0 +1,57 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPrefixGate(t *testing.T) { + var got string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Path + w.WriteHeader(200) + }) + allow := func(r *http.Request) bool { return r.Method == "POST" && r.URL.Path == "/migrate/v1/import" } + h := prefixGate([]string{"s3cretPath", "0ldPanel_x"}, next, allow) + + cases := []struct { + method, remote, path string + code int + seen, location string + }{ + {"GET", "203.0.113.5:4000", "/", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/ui/brand", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPathX/", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPath", 301, "", "s3cretPath/"}, + {"GET", "203.0.113.5:4000", "/s3cretPath/", 200, "/", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPath/assets/a.js", 200, "/assets/a.js", ""}, + {"GET", "203.0.113.5:4000", "/0ldPanel_x/api/v1/users", 200, "/api/v1/users", ""}, + {"POST", "203.0.113.5:4000", "/migrate/v1/import", 200, "/migrate/v1/import", ""}, + {"GET", "203.0.113.5:4000", "/migrate/v1/import", 404, "", ""}, + {"GET", "127.0.0.1:4000", "/metrics", 200, "/metrics", ""}, + {"GET", "[::1]:4000", "/", 200, "/", ""}, + } + for _, c := range cases { + got = "" + r := httptest.NewRequest(c.method, c.path, nil) + r.RemoteAddr = c.remote + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != c.code || got != c.seen || w.Header().Get("Location") != c.location { + t.Errorf("%s %s от %s: %d, роутер видит %q, Location %q; ждали %d, %q, %q", + c.method, c.path, c.remote, w.Code, got, w.Header().Get("Location"), c.code, c.seen, c.location) + } + if c.code == 404 && w.Body.String() != "404 page not found\n" { + t.Errorf("%s: тело 404 не как у пустого Go-сервера: %q", c.path, w.Body.String()) + } + } + + got = "" + r := httptest.NewRequest("GET", "/ui/brand", nil) + r.RemoteAddr = "203.0.113.5:4000" + prefixGate(nil, next, nil).ServeHTTP(httptest.NewRecorder(), r) + if got != "/ui/brand" { + t.Errorf("без префикса запрос должен доходить до роутера как есть, дошёл %q", got) + } +} diff --git a/internal/ui/server.go b/internal/ui/server.go index ef8e754..9aa19f7 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -91,6 +91,7 @@ func decodeLikeAxios(raw []byte) any { type App struct { port int + prefixes []string // UI_PREFIX, UI_PREFIX_MOVED: секретные пути панели (prefix.go) uiUser string uiPass string jwtSecret []byte @@ -131,14 +132,15 @@ func New() (*App, error) { return def } a := &App{ - port: envNum("PORT"), - uiUser: envOr("UI_USER", "admin"), - uiPass: os.Getenv("UI_PASS"), - brand: pick("BRAND", "Forgetting"), - channel: pick("CHANNEL", "Beta"), - version: pick("VERSION", ""), - ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"), - baseDir: cwd, + port: envNum("PORT"), + prefixes: uiPrefixes(), + uiUser: envOr("UI_USER", "admin"), + uiPass: os.Getenv("UI_PASS"), + brand: pick("BRAND", "Forgetting"), + channel: pick("CHANNEL", "Beta"), + version: pick("VERSION", ""), + ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"), + baseDir: cwd, // awg-ctrl на loopback: системный прокси сюда не нужен. httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}}, revoked: map[string]bool{}, @@ -847,8 +849,9 @@ func (a *App) routes() *Router { app.Post("/login", a.login) app.Post("/logout", auth, a.logout) - // Единственный роут без авторизации: вордмарк нужен уже на экране логина. - app.Get("/ui/brand", func(c *Ctx) bool { + // За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting. + // Экран логина обходится без вордмарка. + app.Get("/ui/brand", auth, func(c *Ctx) bool { return c.JSON(200, jsjson.Object{{Key: "brand", Val: a.brand}, {Key: "channel", Val: a.channel}, {Key: "version", Val: a.version}}) }) app.Get("/ui/apikeys", auth, a.apiKeysList) @@ -876,6 +879,7 @@ func (a *App) routes() *Router { app.Use("/ui/metrics-token", auth) app.Mount("/ui/metrics-token", a.metrics.router()) app.Get("/metrics", a.metrics.requireToken, a.metrics.handler) + app.Get("/ui/connlog", auth, a.connlog) app.Use("/ui/monitor", auth) app.Mount("/ui/monitor", a.monitor.router()) // Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса. @@ -949,7 +953,7 @@ func (a *App) Run() error { if err != nil { return err } - srv := &http.Server{Handler: a.routes(), ReadHeaderTimeout: 30 * time.Second} + srv := &http.Server{Handler: prefixGate(a.prefixes, a.routes(), a.migration.awaitsImport), ReadHeaderTimeout: 30 * time.Second} logf("ui: listening on :%d", a.port) a.nodes.Start() a.sub.Start() diff --git a/internal/ui/sub.go b/internal/ui/sub.go index 79aada4..3ed65ef 100644 --- a/internal/ui/sub.go +++ b/internal/ui/sub.go @@ -288,6 +288,13 @@ func (s *Sub) initSchema() error { rekey_requested INTEGER NOT NULL DEFAULT 0, UNIQUE (master_id, device_id) ); + -- Удалённые устройства: ключ авторизации, чтобы проверить подпись их опроса и ответить + -- 410 только им (stealth). Устройства, удалённые до таблицы, получают тихий 404. + CREATE TABLE IF NOT EXISTS revoked_devices ( + id INTEGER PRIMARY KEY, + auth_pub TEXT NOT NULL, + revoked_at INTEGER NOT NULL + ); -- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и -- дальше не читается (кроме переноса ниже). CREATE TABLE IF NOT EXISTS master_servers ( @@ -749,6 +756,9 @@ func (s *Sub) forgetDevice(q queryer, dev *deviceRow) []int { q.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", l.ServerID, l.PeerPub.String) } } + // Ключ авторизации удалённого устройства — чтобы на его подписанный опрос ответить 410 + // (снять конфиг), а на подобранный id без подписи — тихим 404. + q.Exec("INSERT OR REPLACE INTO revoked_devices (id, auth_pub, revoked_at) VALUES (?, ?, ?)", dev.ID, dev.AuthPub, now()) q.Exec("DELETE FROM devices WHERE id = ?", dev.ID) // device_peers уйдут каскадом ids := []int{} for _, l := range links { @@ -903,21 +913,23 @@ func reqPath(r *http.Request) string { } // signFailure — подпись запроса и защита от повторов. "" — всё верно, иначе причина (для лога). -func (s *Sub) signFailure(c *Ctx, authPub []byte) string { +// signed — подпись сама по себе верна (провал только по времени или повтору): запрос сделан +// ключом устройства, ему можно отвечать по протоколу. Без неё — тихий 404 (stealth). +func (s *Sub) signFailure(c *Ctx, authPub []byte) (why string, signed bool) { tsF := jsToNumber(strings.Join(c.R.Header["X-Sen-Ts"], ", ")) if _, has := c.R.Header["X-Sen-Ts"]; !has { tsF = jsToNumber("x") } sig := c.R.Header.Get("X-Sen-Sig") if tsF != float64(int64(tsF)) || sig == "" { - return "no_signature" + return "no_signature", false } ts := int64(tsF) - if d := ts - now(); d > tsWindow || d < -tsWindow { - return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d) - } if !verifyRequest(c.R.Method, reqPath(c.R), ts, c.RawBody, sig, authPub) { - return "bad_signature" + return "bad_signature", false + } + if d := ts - now(); d > tsWindow || d < -tsWindow { + return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d), true } s.sigMu.Lock() defer s.sigMu.Unlock() @@ -928,10 +940,27 @@ func (s *Sub) signFailure(c *Ctx, authPub []byte) string { } } if _, ok := s.seenSigs[sig]; ok { - return "replay" + return "replay", true } s.seenSigs[sig] = t.Add(2 * tsWindow * time.Second) - return "" + return "", true +} + +// stealth — ответ тому, кто не доказал знание ни секрета ссылки, ни ключа устройства: как у +// пустого Go-сервера, без подписи и JSON. Иначе сканер одним запросом узнавал бы подписку (а с +// публичным ключом из любой ссылки — и конкретный сервер). Клиенту с верной ссылкой это не мешает. +func stealth(c *Ctx) bool { + http.NotFound(c.W, c.R) + return false +} + +// frozenFor — перенос панели идёт: доказавшему клиенту — подписанный 502 (он повторит позже). +func (s *Sub) frozenFor(c *Ctx) bool { + if s.frozen.Load() { + s.sendC(c, 502, subErr("unavailable")) + return true + } + return false } func (s *Sub) deviceAuth(c *Ctx) bool { @@ -942,16 +971,31 @@ func (s *Sub) deviceAuth(c *Ctx) bool { if valid { dev = s.deviceByID(s.a.db, int64(f)) } + if !valid { + return stealth(c) + } // Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и // не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за NAT. - if dev == nil && valid { + // Только если запрос подписан ключом удалённого устройства (revoked_devices): иначе на + // подобранный id сканер получал бы ответ протокола. + if dev == nil { + var authPub string + if s.a.db.QueryRow("SELECT auth_pub FROM revoked_devices WHERE id = ?", int64(f)).Scan(&authPub) != nil { + return stealth(c) + } + if _, signed := s.signFailure(c, fromB64url(authPub)); !signed { + return stealth(c) + } return s.sendC(c, 410, subErr("revoked")) } - why := "no_device" - if dev != nil { - why = s.signFailure(c, fromB64url(dev.AuthPub)) + why, signed := s.signFailure(c, fromB64url(dev.AuthPub)) + if !signed { + return stealth(c) } if why == "" { + if s.frozenFor(c) { + return false + } c.device = dev return true } @@ -959,11 +1003,7 @@ func (s *Sub) deviceAuth(c *Ctx) bool { if !s.failLimit.allow(ip) { return s.sendC(c, 429, subErr("rate_limited")) } - hdr := c.R.Header.Get("X-Sen-Device") - if !hasHdr { - hdr = "-" - } - warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), hdr, why, ip) + warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), c.R.Header.Get("X-Sen-Device"), why, ip) return s.sendC(c, 401, subErr("unauthorized")) } @@ -986,10 +1026,6 @@ var errLimit = errors.New("limit") func (s *Sub) register(c *Ctx) error { ip := c.remoteIP() - if !s.registerLimit.allow(ip) { - s.sendC(c, 429, subErr("rate_limited")) - return nil - } b := c.Obj() strRe := func(k string, re *regexp.Regexp) (string, bool) { v, ok := b.Get2(k).(string) @@ -1010,7 +1046,23 @@ func (s *Sub) register(c *Ctx) error { if !ok5 { version = "" } - if !ok1 || !ok2 || !ok3 || !ok4 { + // Доказательство — секрет ссылки: без верного секрета ответа протокола нет (stealth). + var master *masterRow + if ok1 { + master = s.masterBySecret(fromB64url(secretB)) + } + if master == nil { + stealth(c) + return nil + } + if s.frozenFor(c) { + return nil + } + if !s.registerLimit.allow(ip) { + s.sendC(c, 429, subErr("rate_limited")) + return nil + } + if !ok2 || !ok3 || !ok4 { s.sendC(c, 400, subErr("bad_request")) return nil } @@ -1019,7 +1071,7 @@ func (s *Sub) register(c *Ctx) error { authPub := fromB64url(authPubS) why := "bad_auth_pub" if len(authPub) == 32 { - why = s.signFailure(c, authPub) + why, _ = s.signFailure(c, authPub) } if why != "" { warnf("sub: 401 register %s ip=%s", why, ip) @@ -1027,11 +1079,6 @@ func (s *Sub) register(c *Ctx) error { return nil } - master := s.masterBySecret(fromB64url(secretB)) - if master == nil { - s.sendC(c, 404, subErr("not_found")) - return nil - } var serverIDs []int for _, id := range s.serverIDsOf(s.a.db, master.ID) { if s.a.nodes.Exists(id) { @@ -1092,23 +1139,18 @@ func (s *Sub) register(c *Ctx) error { // peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет). func (s *Sub) peek(c *Ctx) error { - ip := c.remoteIP() secretB, ok := c.Obj().Get2("sub").(string) ok = ok && reSubSecret.MatchString(secretB) var master *masterRow if ok { master = s.masterBySecret(fromB64url(secretB)) } + // Секрет не тот — ответа протокола нет (stealth): иначе сканер отличал бы подписку. if master == nil { - if !s.failLimit.allow(ip) { - s.sendC(c, 429, subErr("rate_limited")) - return nil - } - if ok { - s.sendC(c, 404, subErr("not_found")) - } else { - s.sendC(c, 400, subErr("bad_request")) - } + stealth(c) + return nil + } + if s.frozenFor(c) { return nil } s.sendC(c, 200, jsjson.Object{{Key: "key_uuid", Val: master.UUID}, {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)}, @@ -1199,18 +1241,12 @@ func (s *Sub) publicRouter() *Router { rt := newRouter() rt.bodyLimit = 4 * 1024 // Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять. - rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) { - s.send(w, r, 400, subErr("bad_request")) - } - rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) } - // Перенос панели: изменения сейчас остались бы только в уезжающей базе. 502 unavailable - // клиент понимает как «подписка недоступна» — туннель не трогает и повторит позже. - rt.Use("/", func(c *Ctx) bool { - if s.frozen.Load() { - return s.sendC(c, 502, subErr("unavailable")) - } - return true - }) + // Всё, что не доказало знание секрета ссылки или ключа устройства, получает ответ пустого + // Go-сервера (stealth): битое тело, чужой путь, чужой метод. Подписанные ответы — только + // своим. Перенос панели (frozen) — тоже только им: 502 unavailable клиент понимает как + // «подписка недоступна», туннель не трогает и повторит позже (frozenFor). + rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) { http.NotFound(w, r) } + rt.notFound = func(c *Ctx) { stealth(c) } rt.Post("/sub/v1/register", s.wrapSub(s.register)) rt.Post("/sub/v1/peek", s.wrapSub(s.peek)) rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config)) diff --git a/internal/ui/sub_stealth_test.go b/internal/ui/sub_stealth_test.go new file mode 100644 index 0000000..de07d4b --- /dev/null +++ b/internal/ui/sub_stealth_test.go @@ -0,0 +1,105 @@ +package ui + +import ( + "crypto/ed25519" + "crypto/rand" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +// Публичный листенер подписки: ответ протокола (подписанный JSON) — только тому, кто знает +// секрет ссылки или ключ устройства. Остальным — 404 пустого Go-сервера. +func TestSubStealth(t *testing.T) { + a := testApp(t, t.TempDir(), "on") + _, signKey, _ := ed25519.GenerateKey(rand.Reader) + s := &Sub{a: a, signKey: signKey, seenSigs: map[string]time.Time{}} + s.registerLimit = newLimiter(30, time.Minute, func(string) {}) + s.failLimit = newLimiter(30, time.Minute, func(string) {}) + if err := s.initSchema(); err != nil { + t.Fatal(err) + } + devPub, devKey, _ := ed25519.GenerateKey(rand.Reader) + oldPub, oldKey, _ := ed25519.GenerateKey(rand.Reader) + _, strangerKey, _ := ed25519.GenerateKey(rand.Reader) + secret := make([]byte, 16) + rand.Read(secret) + for _, q := range []struct { + sql string + args []any + }{ + {"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k', ?, 1)", []any{secret}}, + {"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', ?, 1)", []any{b64url.EncodeToString(devPub)}}, + {"INSERT INTO revoked_devices (id, auth_pub, revoked_at) VALUES (6, ?, 1)", []any{b64url.EncodeToString(oldPub)}}, + } { + if _, err := a.db.Exec(q.sql, q.args...); err != nil { + t.Fatalf("%s: %v", q.sql, err) + } + } + + rt := s.publicRouter() + do := func(method, path, body string, hdr map[string]string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.RemoteAddr = "203.0.113.5:4000" + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + for k, v := range hdr { + r.Header.Set(k, v) + } + w := httptest.NewRecorder() + rt.ServeHTTP(w, r) + return w + } + signed := func(key ed25519.PrivateKey, dev int, method, path string, ts int64) map[string]string { + sig := ed25519.Sign(key, []byte(requestSigString(method, path, ts, nil))) + return map[string]string{"X-Sen-Device": strconv.Itoa(dev), "X-Sen-Ts": strconv.FormatInt(ts, 10), + "X-Sen-Sig": b64url.EncodeToString(sig)} + } + stealthy := func(name string, w *httptest.ResponseRecorder) { + t.Helper() + if w.Code != 404 || w.Body.String() != "404 page not found\n" { + t.Errorf("%s: ждали тихий 404, получили %d %q", name, w.Code, w.Body.String()) + } + } + protocol := func(name string, w *httptest.ResponseRecorder, code int) { + t.Helper() + if w.Code != code || !strings.Contains(w.Body.String(), `"sig"`) { + t.Errorf("%s: ждали подписанный %d, получили %d %q", name, code, w.Code, w.Body.String()) + } + } + sec := `{"sub":"` + b64url.EncodeToString(secret) + `"}` + other := make([]byte, 16) + rand.Read(other) + now := time.Now().Unix() + + stealthy("GET /", do("GET", "/", "", nil)) + stealthy("чужой путь", do("GET", "/sub/v1/nope", "", nil)) + stealthy("чужой метод", do("GET", "/sub/v1/peek", "", nil)) + stealthy("битый JSON", do("POST", "/sub/v1/peek", "{", nil)) + stealthy("peek без секрета", do("POST", "/sub/v1/peek", `{"sub":"x"}`, nil)) + stealthy("peek чужой секрет", do("POST", "/sub/v1/peek", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil)) + stealthy("register чужой секрет", do("POST", "/sub/v1/register", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil)) + stealthy("config без заголовков", do("GET", "/sub/v1/config", "", nil)) + stealthy("config чужой подписью", do("GET", "/sub/v1/config", "", signed(strangerKey, 5, "GET", "/sub/v1/config", now))) + stealthy("config подобранный id", do("GET", "/sub/v1/config", "", signed(strangerKey, 999, "GET", "/sub/v1/config", now))) + stealthy("config удалённого чужой подписью", do("GET", "/sub/v1/config", "", signed(strangerKey, 6, "GET", "/sub/v1/config", now))) + + protocol("peek своим секретом", do("POST", "/sub/v1/peek", sec, nil), 200) + protocol("register своим секретом, без полей", do("POST", "/sub/v1/register", sec, nil), 400) + protocol("удалённое устройство своей подписью", do("GET", "/sub/v1/config", "", signed(oldKey, 6, "GET", "/sub/v1/config", now)), 410) + protocol("часы устройства уехали", do("GET", "/sub/v1/config", "", signed(devKey, 5, "GET", "/sub/v1/config", now-3600)), 401) + + s.frozen.Store(true) + stealthy("перенос: чужой", do("POST", "/sub/v1/peek", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil)) + protocol("перенос: свой", do("POST", "/sub/v1/peek", sec, nil), 502) + protocol("перенос: устройство", do("GET", "/sub/v1/devices", "", signed(devKey, 5, "GET", "/sub/v1/devices", now)), 502) + s.frozen.Store(false) + + // Удаление устройства оставляет его ключ в revoked_devices: дальше — 410 только ему. + s.forgetDeviceTx(s.deviceByID(a.db, 5)) + protocol("после удаления — своей подписью", do("GET", "/sub/v1/config", "", signed(devKey, 5, "GET", "/sub/v1/config", now+1)), 410) + stealthy("после удаления — чужой", do("GET", "/sub/v1/config", "", signed(strangerKey, 5, "GET", "/sub/v1/config", now))) +} diff --git a/tools/diffbench/scenario-ui.ts b/tools/diffbench/scenario-ui.ts index 3bf6c5c..b910a71 100644 --- a/tools/diffbench/scenario-ui.ts +++ b/tools/diffbench/scenario-ui.ts @@ -62,6 +62,7 @@ try { await call("login broken json", "POST", "/login", undefined, { raw: "{" }); const { token } = await call("login", "POST", "/login", { user: "admin", pass: "benchpass" }); const T = { token }; + await call("brand auth", "GET", "/ui/brand", undefined, T); await call("apikeys no auth", "GET", "/ui/apikeys"); await call("apikeys bad token", "GET", "/ui/apikeys", undefined, { token: "a.b.c" }); diff --git a/tools/release/package.sh b/tools/release/package.sh index a99eb34..3af0593 100755 --- a/tools/release/package.sh +++ b/tools/release/package.sh @@ -1,6 +1,7 @@ #!/bin/sh # package.sh <версия> — архивы релиза: по одному на архитектуру, awgcontrol-<версия>-linux-.tar.gz. -# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env. +# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env и +# install.sh (им `awg-ctrl uninstall` удаляет установку без сети). # Фронтенд должен быть собран заранее (npm run build в awg-ui). Общий для GitHub и Gitea. set -eu VERSION="$1" @@ -11,7 +12,7 @@ for ARCH in amd64 arm64; do CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ -o "$D/awg-control" ./cmd/awg-control cp -r awg-ui/dist "$D/awg-ui/dist" - cp .env "$D/" + cp .env install.sh "$D/" tar -czf "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz" "$D/" rm -rf "$D" echo "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz"