Brings in 0.4.8: connection log, uninstall and update from the awg-ctrl menu, secret panel path, stealth hub and sen:// port, random I1/MTU on install. install.sh keeps the new output (spinner, cards, outdated-install wipe) on top of the reorganised installer; an outdated install can be reinstalled with a wipe or removed. The connection log tab uses the MD3 segmented button; its metrics-token scrape snippet moved to Settings.
This commit is contained in:
@@ -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 '<awgjoin://…>'`. Агент ноды сам подключается к хабу панели по 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 <PORT>/tcp`) — порты
|
||||
подписки (`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты
|
||||
и ноды ходят на них напрямую, не через nginx.
|
||||
Сам порт панели после этого можно закрыть снаружи (`ufw deny <UI_PORT>/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: <id>`, сервер вне набора → `404`.
|
||||
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; `0` — сервер
|
||||
самой панели, остальные — подключённые ноды). Пользователи `vpn://` заводятся на сервере ключа
|
||||
по умолчанию; другой сервер из набора выбирается заголовком `X-Server-Id: <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": "<JWT>" }`, срок 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=<id>` — следующая страница, `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 '<awgjoin://…>' [--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
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AWG Panel</title>
|
||||
<title>Вход</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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 включено деление канала, первый пользователь «фоновый».
|
||||
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
|
||||
|
||||
+3
-3
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
+8
-5
@@ -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() {
|
||||
<div className="login-screen">
|
||||
<span className="theme-toggle--fixed">{themeToggle}</span>
|
||||
<div className="login-card">
|
||||
<span className="logo-name">{brand.brand}</span>
|
||||
<span className="logo-name">Вход</span>
|
||||
<p className="login-sub">Войдите, чтобы продолжить</p>
|
||||
<input
|
||||
className="field"
|
||||
|
||||
@@ -132,6 +132,12 @@ export const IcoMore = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcoHistory = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ICONS: Record<string, () => JSX.Element> = {
|
||||
settings: IcoGear,
|
||||
users: IcoUsers,
|
||||
@@ -139,4 +145,5 @@ export const ICONS: Record<string, () => JSX.Element> = {
|
||||
shield: IcoShield,
|
||||
pulse: IcoPulse,
|
||||
move: IcoMove,
|
||||
history: IcoHistory,
|
||||
};
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<Log | null>(null);
|
||||
const [more, setMore] = useState<Ev[]>([]); // подгруженные страницы старее первой
|
||||
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 (
|
||||
<div className="cl">
|
||||
<div className="cl-toolbar">
|
||||
<div className="segmented cl-views" role="radiogroup" aria-label="Вид">
|
||||
<button type="button" role="radio" aria-checked={view === 'events'} className="segmented-item"
|
||||
onClick={() => setView('events')}>{view === 'events' && <IcoCheck />}События</button>
|
||||
<button type="button" role="radio" aria-checked={view === 'peers'} className="segmented-item"
|
||||
onClick={() => setView('peers')}>{view === 'peers' && <IcoCheck />}Кто когда был</button>
|
||||
</div>
|
||||
<input className="field cl-search" type="search" placeholder="Имя, ключ, устройство или IP"
|
||||
aria-label="Поиск" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<button className="btn btn--tonal" onClick={() => void load()} aria-label="Обновить"><IcoRefresh /> Обновить</button>
|
||||
</div>
|
||||
|
||||
{err && <p className="login-error cl-err">{err}</p>}
|
||||
{!log && !err && <p className="cl-hint">Загрузка…</p>}
|
||||
|
||||
{log && (
|
||||
<p className="cl-hint">
|
||||
Сейчас онлайн: <b>{onlineNow}</b> из {log.peers.length}.
|
||||
{' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней.</> : 'Журнал пуст: события появятся при первом подключении.'}
|
||||
{' '}Время отключения — последняя активность, точность около 30 секунд.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{log && view === 'events' && (
|
||||
<ul className="cl-list">
|
||||
{events.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'Событий пока нет'}</li>}
|
||||
{events.map(e => {
|
||||
const w = who(e);
|
||||
const on = e.event === 'connect';
|
||||
return (
|
||||
<li className="cl-row" key={e.id}>
|
||||
<span className="cl-time">{when(e.at)}</span>
|
||||
<span className={`chip chip--${on ? 'online' : 'offline'} cl-chip`}>
|
||||
<span className="chip-dot" />{on ? 'подключился' : 'отключился'}
|
||||
</span>
|
||||
<span className="cl-who">
|
||||
<b>{w.title}</b>
|
||||
<span className="cl-sub">{w.sub}{e.ip && <> · {e.ip}</>}</span>
|
||||
</span>
|
||||
{!on && (
|
||||
<span className="cl-session">
|
||||
{e.duration != null && <>сессия {duration(e.duration)}</>}
|
||||
{/* rx/tx — со стороны сервера: то, что сервер принял, клиент отправил */}
|
||||
{(e.tx || e.rx) ? <> · ↓ {bytes(e.tx ?? 0)} ↑ {bytes(e.rx ?? 0)}</> : null}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!done && !q && all.length > 0 && (
|
||||
<li className="cl-more">
|
||||
<button className="btn btn--tonal" onClick={() => void loadMore()} disabled={busy}>
|
||||
{busy ? 'Загрузка…' : 'Показать раньше'}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{log && view === 'peers' && (
|
||||
<ul className="cl-list">
|
||||
{peers.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'На сервере нет пользователей'}</li>}
|
||||
{peers.map(p => {
|
||||
const w = who(p);
|
||||
return (
|
||||
<li className="cl-row" key={p.pub_key}>
|
||||
<span className="cl-who">
|
||||
<b>{w.title}</b>
|
||||
<span className="cl-sub">{w.sub}{p.ip && <> · {p.ip}</>}</span>
|
||||
</span>
|
||||
{p.online ? (
|
||||
<span className="chip chip--online cl-chip"><span className="chip-dot" />онлайн</span>
|
||||
) : p.last_seen ? (
|
||||
<span className="cl-seen">был {when(p.last_seen)} <span className="cl-sub">({timeAgo(p.last_seen)} назад)</span></span>
|
||||
) : (
|
||||
<span className="chip chip--offline cl-chip"><span className="chip-dot" />не подключался{log.since ? ` с ${when(log.since)}` : ''}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "connlog",
|
||||
"label": "Подключения",
|
||||
"icon": "history",
|
||||
"group": "main",
|
||||
"order": 1.5
|
||||
}
|
||||
@@ -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) {
|
||||
<div className="qr-card" role="dialog" aria-modal="true" aria-labelledby="st-new-title" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name" id="st-new-title">Токен метрик выпущен</p>
|
||||
<p className="qr-hint">
|
||||
Скопируй его сейчас — он показывается один раз. В prometheus.yml он идёт
|
||||
в <code className="st-code">authorization.credentials</code>.
|
||||
Скопируй его сейчас — он показывается один раз. Готовый фрагмент для prometheus.yml:
|
||||
</p>
|
||||
<code className="st-secret">{created}</code>
|
||||
<pre className="st-secret st-yml">{scrapeConfig(created)}</pre>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
|
||||
<button className="btn btn--primary" onClick={async () => {
|
||||
<button className="btn btn--tonal" onClick={async () => {
|
||||
await copyText(created);
|
||||
showMsg('Токен скопирован');
|
||||
}}><IcoCopy /> Скопировать</button>
|
||||
}}><IcoCopy /> Токен</button>
|
||||
<button className="btn btn--primary" onClick={async () => {
|
||||
await copyText(scrapeConfig(created));
|
||||
showMsg('Фрагмент prometheus.yml скопирован');
|
||||
}}><IcoCopy /> Фрагмент</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+937
-603
File diff suppressed because it is too large
Load Diff
+28
-1
@@ -198,6 +198,26 @@ func authMsg(nonce string, nodeID int) []byte {
|
||||
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID))
|
||||
}
|
||||
|
||||
// preauthMsg — подпись в заголовке запроса на WebSocket: хаб проверяет её до апгрейда и без неё
|
||||
// отвечает 404, как пустой сервер (internal/ui/hub_preauth.go, там же копия).
|
||||
func preauthMsg(nodeID int, ts int64) []byte {
|
||||
return []byte("awg-node-pre-v1\n" + strconv.Itoa(nodeID) + "\n" + strconv.FormatInt(ts, 10))
|
||||
}
|
||||
|
||||
// preauthHeader — подпись ноды (и при первой привязке — секрет с ключом) для запроса на WebSocket.
|
||||
func (a *Agent) preauthHeader() http.Header {
|
||||
ts := time.Now().Unix()
|
||||
h := http.Header{}
|
||||
h.Set("X-Node-Id", strconv.Itoa(a.c.NodeID))
|
||||
h.Set("X-Node-Ts", strconv.FormatInt(ts, 10))
|
||||
h.Set("X-Node-Sig", b64.EncodeToString(ed25519.Sign(a.nodeKey, preauthMsg(a.c.NodeID, ts))))
|
||||
if s := a.secret(); s != "" {
|
||||
h.Set("X-Node-Join", s)
|
||||
h.Set("X-Node-Pub", a.nodePubRaw)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
|
||||
|
||||
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
|
||||
@@ -476,13 +496,20 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
||||
|
||||
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
|
||||
dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout)
|
||||
ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{
|
||||
ws, resp, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{
|
||||
HTTPClient: &http.Client{Transport: tr},
|
||||
HTTPHeader: a.preauthHeader(),
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
|
||||
})
|
||||
dcancel()
|
||||
if err != nil {
|
||||
// 404 до апгрейда — хаб не принял подпись (или IP временно заблокирован за неудачи):
|
||||
// как отказ в auth, переподключение — не чаще раза в минуту.
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
a.c.Errorf("agent: core отклонил ноду до подключения (неверный ключ, просроченная или использованная ссылка, часы ноды сильно расходятся)")
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, errPin) {
|
||||
a.c.Errorf("agent: %v", errPin)
|
||||
} else {
|
||||
|
||||
+42
-2
@@ -165,6 +165,9 @@ func New() *CLI {
|
||||
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
|
||||
env: [][2]string{
|
||||
{"PORT", env("UI_PORT", "8080")},
|
||||
// Секретный путь панели; _MOVED — путь панели, переехавшей сюда (принимаются оба).
|
||||
{"UI_PREFIX", env("UI_PREFIX", "")},
|
||||
{"UI_PREFIX_MOVED", env("UI_PREFIX_MOVED", "")},
|
||||
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
|
||||
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
|
||||
{"BRAND", c.brand}, {"CHANNEL", c.channel}, {"VERSION", c.version},
|
||||
@@ -182,6 +185,8 @@ func New() *CLI {
|
||||
{"SUB_HOST", env("SUB_HOST", "")},
|
||||
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
|
||||
{"NODE_PORT", env("NODE_PORT", "")},
|
||||
// Хаб: пускать ли агентов без подписи до апгрейда (on/off; пусто — пока не обновлены все).
|
||||
{"HUB_LEGACY", env("HUB_LEGACY", "")},
|
||||
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
|
||||
{"RELAY_FROM", env("RELAY_FROM", "")},
|
||||
// Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP.
|
||||
@@ -477,11 +482,33 @@ func (c *CLI) serviceLine(name string, menu bool) string {
|
||||
return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info)
|
||||
}
|
||||
|
||||
// panelURL — адрес панели с секретным путём; без UI_PORT (нода) — пусто.
|
||||
func panelURL() string {
|
||||
port := env("UI_PORT", "")
|
||||
if port == "" {
|
||||
return ""
|
||||
}
|
||||
host := env("SUB_HOST", env("SERVER_IP", "IP"))
|
||||
if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
u := "http://" + host + ":" + port + "/"
|
||||
if p := env("UI_PREFIX", ""); p != "" {
|
||||
u += p + "/"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (c *CLI) status() {
|
||||
fmt.Println()
|
||||
for _, n := range c.all {
|
||||
fmt.Println(c.serviceLine(n, false))
|
||||
}
|
||||
if contains(c.all, "ui") {
|
||||
if u := panelURL(); u != "" {
|
||||
fmt.Println(" " + dim("Панель "+u))
|
||||
}
|
||||
}
|
||||
if c.isRunning("awgctrl") {
|
||||
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
|
||||
if gen, ok := get(h, "gen").(string); ok && gen != "" {
|
||||
@@ -611,10 +638,10 @@ func (c *CLI) confirmYes(q string) bool {
|
||||
|
||||
// ── Точка входа ─────────────────────────────────────────────────────────────
|
||||
|
||||
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>>"
|
||||
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>|update|reinstall|uninstall|panel [path new|off|<путь>]|log [seen] [поиск] [-n N]|metrics [token|revoke]|hub [legacy on|off|auto]|check>"
|
||||
|
||||
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
|
||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"}
|
||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall", "panel", "log", "metrics", "hub", "check"}
|
||||
|
||||
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
|
||||
func Main(args []string) {
|
||||
@@ -659,6 +686,19 @@ func Main(args []string) {
|
||||
c.peersCmd(sub, hasSub, a2, hasA2)
|
||||
case "monitor":
|
||||
c.monitorCmd(sub)
|
||||
case "panel":
|
||||
c.panelCmd(args[1:])
|
||||
case "log":
|
||||
c.logCmd(args[1:])
|
||||
case "metrics":
|
||||
c.metricsCmd(args[1:])
|
||||
case "hub":
|
||||
c.hubCmd(args[1:])
|
||||
case "check":
|
||||
c.checkCmd()
|
||||
case "update", "reinstall", "uninstall":
|
||||
// Из командной строки — без своего вопроса: установщик сам спросит, что нужно.
|
||||
c.runInstaller(args[0], false)
|
||||
default:
|
||||
fmt.Println(usage)
|
||||
os.Exit(1)
|
||||
|
||||
+28
-1
@@ -454,6 +454,9 @@ func (c *CLI) setCredentials(field string) {
|
||||
if pass != nil {
|
||||
fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass)
|
||||
}
|
||||
if u := panelURL(); u != "" {
|
||||
fmt.Printf(" %s Панель: %s\n", green("✓"), u)
|
||||
}
|
||||
fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу."))
|
||||
}
|
||||
|
||||
@@ -488,9 +491,19 @@ func (c *CLI) interactiveMenu() {
|
||||
%s Сменить логин UI
|
||||
%s Сменить пароль UI
|
||||
%s
|
||||
%s Обновить
|
||||
%s Переустановить
|
||||
%s Удалить
|
||||
%s
|
||||
%s Адрес панели и секретный путь
|
||||
%s Журнал подключений
|
||||
%s Кто когда был в VPN
|
||||
%s Проверка скрытности
|
||||
%s
|
||||
%s Выход
|
||||
|
||||
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("0"))
|
||||
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep,
|
||||
bold("10"), bold("11"), bold("12"), bold("13"), sep, bold("0"))
|
||||
}
|
||||
for {
|
||||
printMenu()
|
||||
@@ -515,6 +528,20 @@ func (c *CLI) interactiveMenu() {
|
||||
c.setCredentials("user")
|
||||
case "6":
|
||||
c.setCredentials("pass")
|
||||
case "7":
|
||||
c.runInstaller("update", true)
|
||||
case "8":
|
||||
c.runInstaller("reinstall", true)
|
||||
case "9":
|
||||
c.runInstaller("uninstall", true)
|
||||
case "10":
|
||||
c.panelMenu()
|
||||
case "11":
|
||||
c.logCmd(nil)
|
||||
case "12":
|
||||
c.logCmd([]string{"seen"})
|
||||
case "13":
|
||||
c.checkCmd()
|
||||
case "0":
|
||||
os.Exit(0)
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
package cli
|
||||
|
||||
// Команды для новых возможностей панели — то же, что в веб-интерфейсе, но с сервера:
|
||||
// panel [path new|off|<путь>] — адрес панели, секретный путь (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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (старый сервер) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<id> — следующая страница, ?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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
+13
-1
@@ -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")},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
-18
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
-11
@@ -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()
|
||||
|
||||
+86
-50
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# package.sh <версия> — архивы релиза: по одному на архитектуру, awgcontrol-<версия>-linux-<arch>.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"
|
||||
|
||||
Reference in New Issue
Block a user