23 Commits
Author SHA1 Message Date
maeneko 7ee126af30 README: install through sudo bash -c
ci / check (push) Successful in 25m56s
2026-10-21 17:29:04 +03:00
maeneko 45c96aa0b6 Bump to Beta 0.4.5
ci / check (push) Successful in 24m11s
release / release (push) Successful in 18m26s
2026-10-21 15:59:41 +03:00
maeneko eb19cacf93 Move the panel to a server without VPN; servers for all master keys at once
The «Перенос» tab moves a panel + VPN server to a panel-only server without
users noticing: the new panel issues a one-time awgmove:// code, the old one
sends its ui.db, subscription keys and hub certificate encrypted with it, and
becomes node #0 of the new panel. Server id 0 is kept, so config servers[] is
byte-identical. The old server relays its former ports (awg-relay, PROXY v1
header trusted only from RELAY_FROM), so sen:// links, nodes and the API keep
working. The same is available from the CLI: migrate export/import/cancel,
rehome.

Nodes now learn the panel's address: the hub sends it in ready, the agent
stores it in CORE_ENDPOINT, dials it first and confirms with core_ok. The
new panel shows when the old server can be turned off: devices that got the
new address, nodes that confirmed it, master keys never opened.

«Серверы для всех» on the master keys tab adds or removes servers on every
live key in one go, with a dry run that warns about API keys that would lose
sight of some keys, and sets the servers new keys get.
2026-10-21 15:59:33 +03:00
maeneko 0687650d26 Bump to Beta 0.4.4
ci / check (push) Successful in 24m29s
release / release (push) Successful in 19m48s
2026-10-20 20:39:09 +03:00
maeneko 3511b8c6c3 Master keys: a down server no longer breaks the device list, 403 for keys outside the API key's scope
masterDevices skips a server that did not answer and reports it as
unreachable; peers are fetched once per server per pass. The tab loads each
key on its own, counts devices from the key when the list is missing (and still
asks before unbinding), drops stale responses, labels an IP from another
server and blocks a double create. masterFor answers 403 instead of 404 for a
key that exists but is outside the API key's servers, so a client does not
mistake it for a deleted one. updateMaster checks every statement in its
transaction.
2026-10-20 20:39:09 +03:00
maeneko 28ccc17bde Bump to Beta 0.4.3
ci / check (push) Successful in 22m56s
release / release (push) Successful in 17m49s
2026-10-19 12:23:21 +03:00
maeneko d6fc2049c3 Master keys tab: same columns as users, device IPs, per-key summary
Status, priority/torrent chips and IP columns like the users tab; the API
returns each device's IP per server. The key row sums its devices (online,
priority, traffic); key UUID and device ID sit under the name, copy on click.
2026-10-19 12:23:21 +03:00
maeneko ea91265674 Bump to Beta 0.4.2.1
ci / check (push) Successful in 22m38s
release / release (push) Successful in 17m42s
2026-10-18 00:09:34 +03:00
maeneko de8a32ac41 Fix awg-ctrl hanging on the torrent flag; log out on an expired session
The torrent flag was read inside loops over users rows (users/stats,
peers, metrics) and each read queried the config table. SQLite has a
single connection here, so the nested query waited for it forever: the
first stats request or Prometheus scrape locked awg-ctrl for good, and
the panel showed no users and timed out.

- the block setting is kept in memory (loaded at start, updated on
  save), so the flag never touches the database
- the set of flagged IPs is taken once before the query, not per row
- regression test: collectMetrics must finish with users in the table

Panel: a 401 from the panel itself (its JWT not accepted) now returns to
the login screen; a 401 relayed from a node's awg-ctrl does not.
2026-10-18 00:08:17 +03:00
maeneko a84845cecb Bump to Beta 0.4.2
ci / check (push) Successful in 22m15s
release / release (push) Successful in 17m39s
2026-10-17 23:35:30 +03:00
maeneko c73da602d6 Block BitTorrent on the server
- awg-ctrl: optional nft table awg_torrent (GET/PUT /api/torrent, off by
  default). A connection is judged by its first packets, like Xray's
  sniffing: DHT, UDP trackers, uTP SYN, and with @ih support the plain
  TCP handshake and HTTP announce. A match marks the connection in
  conntrack and drops it whole; without @ih only the UDP part is used
- devices that tried are kept in a 10-minute set and flagged as
  "torrent" in user and device stats; with shaping on they sit in the
  background class while the flag lasts
- metrics: awg_torrent_block_enabled/active, awg_torrent_blocked_total,
  awg_torrent_devices and per-peer awg_peer_torrent
- panel: "Торренты" section in server settings, "торренты" chip on users
  and devices; Save sends only what changed
2026-10-17 23:35:30 +03:00
maeneko cf6af5ce0a Bump to Beta 0.4.1
ci / check (push) Successful in 31m14s
release / release (push) Successful in 18m43s
2026-10-17 21:36:51 +03:00
maeneko 105f184a93 Monitor: 7 and 30 day views, traffic used, merged tiles
- ranges 7d (30 min step) and 30d (2 h step); Prometheus keeps 31d
- rate() window is at least the step, so long views show averages
  instead of a 2-minute sample; "now" values still use a 2m window
- overview returns used_down/used_up: increase() of the interface
  counters over the whole range
- tiles merged: traffic (with volume for the period), devices, load
2026-10-17 21:36:51 +03:00
maeneko 2388a42275 release: allow a manual run that creates the tag
workflow_dispatch with a tag input; tag pushes work as before.
2026-10-14 14:14:48 +00:00
maeneko e70c5468f5 Bump to Beta 0.4.0, install from GitHub
- version 0.4.0 in install.sh, .env and the service package.json files
- the installer and its release archives come from GitHub by default;
  REPO_BASE / REPO_FALLBACK / ARCHIVE_URL still override the source
- README: install commands point to raw.githubusercontent.com
2026-10-14 13:28:03 +00:00
maeneko bac230f144 Ship and run the Go binary instead of Node
Releases now carry one archive per architecture with a static awg-control
binary (services and CLI), the built frontend and .env; the installer no
longer installs Node.js or runs npm.

- tools/release/package.sh: CGO-free builds for linux/amd64 and arm64,
  version injected into `awg-control version`; used by both the GitHub and
  Gitea release workflows
- install.sh: architecture check, per-arch archive, /usr/local/bin/awg-ctrl
  is a symlink to the binary, systemd unit without node in PATH, steps
  renumbered 1/6..6/6
- update from a Node install: the old services are stopped through the old
  CLI, then TS sources, node_modules and the old dist are removed; cli.env,
  panel.id and logs stay
- README: one binary, its roles, architecture requirement
2026-10-14 13:27:24 +00:00
maeneko 5aa0c3a2f5 Port the CLI to Go: awg-control <start|stop|…>
internal/cli reimplements cli/src: start/stop/restart/status with the
awg-ctrl health gate, credentials, join/unjoin, peers [drop], monitor
install/status/remove and the interactive menu. Same cli.env, pid files,
logs and messages. Without arguments or with an operator command the binary
acts as the CLI; ctrl/ui/agent stay service roles.

- services are started as the same binary (`awg-control ctrl|ui|agent`),
  detached into their own session, from the same working directories
- processes started by the Node CLI (tsx … index.ts / server.ts) are still
  recognised, so `stop` and `status` work across the switch
- after join the fresh restart gets the original environment, so a rejoin
  picks up the new cli.env instead of the inherited old CORE_* values
- install root: AWG_ROOT, else the nearest parent of the binary with cli/ or
  .env, else /opt/awg-control

Checked end to end on stubbed awg: start/status/stop, credentials, join to
the panel's own hub (with and without --no-panel), peers listing and drop,
unjoin, menu.
2026-10-14 13:27:24 +00:00
maeneko 70bb587216 Port awg-ui to Go: awg-control ui
internal/ui reimplements the awg-ui backend with the same HTTP surface; the
React frontend is unchanged and served from awg-ui/dist.

- web.go: Express-like router (express.json semantics, mounts, :params,
  HTML 400/404/413 pages, SPA fallback)
- server.go: JWT login with rate limit, API keys and their server sets,
  awg-ctrl client and proxy, /api/v1/users, delete hooks
- nodes.go: WSS hub for agents (pin, challenge-response, join strings),
  RPC with timeouts, health, /ui/nodes
- sub.go, audit.go, sub_admin.go: sen:// master keys and devices,
  per-server reconcile with tombstones and /api/peers/sync, signed /sub/v1
  listener, audit with two-pass confirmation and cleaning, panel and
  /api/v1/masterkeys routers
- metrics.go, monitor.go: /metrics exposition with tokens, /ui/monitor
- sen.go: link codec and protocol signatures, tested byte for byte against
  fixtures produced by awg-ui/sen.ts (tools/golden/sen.ts)

Differential bench (tools/diffbench/local-ui.sh, scenario-ui.ts,
compare-ui.mjs): 158 steps over every route group, including signed device
requests and a Go agent joining as a node, plus the resulting ui.db and
awg-ctrl state. TS and Go are identical after masking random values, across
repeated runs and with the race detector on.
2026-10-14 13:27:24 +00:00
maeneko c2e9636f31 awg-ctrl: keep CPU mode order in /api/metrics; diffbench compare and host runner
The differential bench (GEN 2 and 3.1, with I1, S4=0, upgrade with a 3.x
module) now shows the Go awg-ctrl identical to the TS one. The only real
difference it found: host.cpu was a Go map, so its keys came out sorted
instead of in /proc/stat order.

- tools/diffbench/compare.mjs: decodes vpn:// (Node and Go zlib differ), masks
  live host numbers, Express dev-mode stack traces, temp file names and the
  order of calls from concurrent background work
- tools/diffbench/local.sh: the same run without Docker, stubs only on the
  run's own PATH (for disposable machines)
- run.sh: wait for the background shaping apply before stopping the service,
  otherwise the stub log is cut short
2026-10-14 13:27:24 +00:00
maeneko 5687ba0d3d Drop awg-ui/package-lock.json added by mistake 2026-10-14 13:27:24 +00:00
maeneko 016d3de62d Port awg-agent to Go: awg-control agent
- internal/agent: outbound WSS to core with SPKI pin checked inside the TLS
  handshake, Ed25519 challenge-response, one-shot join secret (dropped from
  memory and cli.env after the first ready), forwarding of core requests to the
  local awg-ctrl with an internal token, dead-connection timer on pings,
  backoff up to a minute (a minute after 4003)
- path filter is stricter than the Node one where it relied on WHATWG URL
  normalisation: backslashes, a leading "//" and %2e dot segments are rejected
- tests: end-to-end against a fake TLS core and a fake awg-ctrl; also checked
  by hand against the real awg-ui/nodes.ts hub
- CI: Go build, vet and race tests on GitHub and Gitea
2026-10-14 13:27:24 +00:00
maeneko 59e076c1de Start the Go rewrite: jsjson, awg-ctrl port, differential bench
- internal/jsjson: JSON.stringify-compatible encoder and order-preserving decoder,
  tested against fixtures produced by Node itself
- internal/ctrl: awg-ctrl in Go (params, vpn:// keys, users, peers, sync, 3.1
  upgrade, CAKE shaping, metrics, monitor job, Express-compatible HTTP layer)
- internal/iauth, internal/logx: internal Ed25519 tokens and logging
- tools/diffbench: Docker bench with stubbed awg/ip/tc/nft that runs one scenario
  against the TS and Go implementations for comparison
2026-10-14 13:27:24 +00:00
maeneko 53d5a08bbd Add shaping, Prometheus, monitor tab and peer audit
Baseline TypeScript version before the Go rewrite: CAKE fair sharing with
automatic deprioritisation of heavy devices, per-key priority, /metrics with
a metrics token, the built-in Prometheus install/remove, the Monitor tab,
grouped sidebar tabs and the dead-peer audit.
2026-10-14 13:27:23 +00:00
80 changed files with 14098 additions and 299 deletions
+2 -2
View File
@@ -7,8 +7,8 @@
#
# ⚠️ VERSION и CHANNEL продублированы в install.sh: установщик печатает баннер и
# собирает URL релиза ДО того, как архив с этим файлом скачан. При бампе версии
# правь оба места (и три package.json).
# правь оба места (и package.json сервисов).
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.3.4
VERSION=0.4.5
+12
View File
@@ -26,6 +26,18 @@ jobs:
- name: Installer syntax
run: bash -n install.sh
# Go-переписывание (awg-control): сборка, vet и тесты. composites выключен —
# jsjson.KV{k, v} без имён полей написан так намеренно.
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Go build, vet, test
run: |
go build ./...
go vet -composites=false ./...
go test -race ./...
- name: awg-ctrl types
working-directory: awg-ctrl
run: |
+14 -12
View File
@@ -1,5 +1,5 @@
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает тот
# же архив, но заливает его через API Gitea — gh CLI в раннере нет.
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает те
# же архивы, но заливает его через API Gitea — gh CLI в раннере нет.
#
# Gitea читает workflow из .gitea/workflows; когда этот каталог есть, файлы из
# .github/workflows она не запускает, так что GitHub-версия остаётся только для
@@ -39,12 +39,13 @@ jobs:
npm install
npm run build
- name: Package archive
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Архив на архитектуру: Go-бинарник awg-control + собранный фронтенд + .env.
- name: Package archives
run: sh tools/release/package.sh "${GITHUB_REF_NAME#v}"
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
# зависит от версии движка выражений в Gitea.
@@ -58,7 +59,6 @@ jobs:
[ -n "$TOKEN" ] || { echo "нет токена: ни RELEASE_TOKEN, ни GITEA_TOKEN"; exit 1; }
VERSION="${GITHUB_REF_NAME#v}"
ARCHIVE="awgcontrol-${VERSION}.tar.gz"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
# id релиза — первое поле в JSON Gitea, поэтому берём ПЕРВОЕ вхождение
@@ -82,8 +82,10 @@ jobs:
fi
[ -n "$id" ] || { echo "не удалось получить id релиза"; exit 1; }
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
-H "Authorization: token $TOKEN" \
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
for ARCHIVE in awgcontrol-"${VERSION}"-linux-*.tar.gz; do
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
-H "Authorization: token $TOKEN" \
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
done
echo "опубликовано: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
+12
View File
@@ -23,6 +23,18 @@ jobs:
- name: Installer syntax
run: bash -n install.sh
# Go-переписывание (awg-control): сборка, vet и тесты. composites выключен —
# jsjson.KV{k, v} без имён полей написан так намеренно.
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Go build, vet, test
run: |
go build ./...
go vet -composites=false ./...
go test -race ./...
- name: awg-ctrl types
working-directory: awg-ctrl
run: |
+18 -9
View File
@@ -4,6 +4,12 @@ on:
push:
tags:
- 'v*'
# Ручной запуск: тег создаётся при публикации на коммите, с которого запущен workflow.
workflow_dispatch:
inputs:
tag:
description: 'Тег релиза, например v0.4.0'
required: true
permissions:
contents: write
@@ -11,6 +17,8 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
steps:
- uses: actions/checkout@v4
@@ -24,17 +32,18 @@ jobs:
npm install
npm run build
- name: Package archive
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Архив на архитектуру: Go-бинарник awg-control + собранный фронтенд + .env.
- name: Package archives
run: sh tools/release/package.sh "${TAG#v}"
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
--title "${GITHUB_REF_NAME}" --generate-notes --prerelease
VERSION="${TAG#v}"
gh release create "${TAG}" awgcontrol-"${VERSION}"-linux-*.tar.gz \
--target "${GITHUB_SHA}" --title "${TAG}" --generate-notes --prerelease
+29 -11
View File
@@ -4,13 +4,15 @@
> Данный проект предназначен только для использования в законных целях.
## Архитектура
Три компонента, устанавливаются в `/opt/awg-control/`:
Один статический бинарник `awg-control` (Go) в `/opt/awg-control/` — в нём все роли;
команда `awg-ctrl` — ссылка на него:
| Компонент | Роль |
|---|---|
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), прокси к awg-ctrl, публичный API `/api/v1` и своя база его ключей. Единственный сервис, доступный по сети. |
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
| **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-agent** (`awg-control agent`) | Только на ноде: исходящее WSS-соединение к панели, исполняет её запросы на локальном awg-ctrl. |
| **cli** (`awg-ctrl …`) | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
@@ -19,7 +21,7 @@
- **Права**: root.
- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG).
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
- **Node.js 20.x** (ставится установщиком автоматически).
- **Архитектура**: x86_64 или aarch64.
Совместимость установщик проверяет сам перед любыми изменениями.
@@ -28,7 +30,15 @@
На сервере, от root:
```bash
bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
```
Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с
`/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. Если модуль в ядре
@@ -36,7 +46,7 @@ bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/instal
на 2.0 можно так:
```bash
AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
```
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
@@ -149,7 +159,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.5" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
@@ -196,8 +206,9 @@ base64url-декодировать, отбросить первые 4 байта
| Метод | Что делает |
|---|---|
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`; `:id` в путях — id или `uuid` |
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` |
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }], defaults }`; `:id` в путях — id или `uuid`; `defaults` — серверы новых ключей или `null` |
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201`; без `servers` — `defaults`, а нет их — сервер по умолчанию |
| `POST /ui/masterkeys/servers` | «Серверы для всех»: `{ add?, remove?, defaults?, dry_run? }` — `add` появляются у всех живых ключей, `remove` снимаются у всех, прочие серверы ключей не трогаются; `defaults` (`null` — сбросить) — набор новых ключей. → `{ changed, total, hidden: [{ id, label, keys }] }`, `hidden` — ключи API, которые перестанут видеть столько мастер-ключей (сервер вне их области). `409`, если у ключа не останется серверов |
| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
@@ -209,7 +220,14 @@ base64url-декодировать, отбросить первые 4 байта
## Управление
```bash
awg-ctrl
awg-ctrl # меню
awg-ctrl start|stop|restart|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 migrate cancel
```
Через systemd (автозапуск на загрузке):
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-agent",
"version": "0.3.4",
"version": "0.4.5",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.3.4",
"version": "0.4.5",
"description": "",
"main": "dist/index.js",
"scripts": {
+111 -11
View File
@@ -53,11 +53,17 @@ interface Dev { id: number; device_id: string; device_name: string; platform: st
interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
let mkId = 1, devId = 1;
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] },
{ id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0] },
{ id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }];
const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
const devOut = (m: MK, d: Dev) => ({ ...d, priority: shapingOf[0]?.enabled && d.id === masters[0]?.devs[0]?.id ? "background" : "normal", servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
const devTorrent = (d: Dev) => !!torrentOf[0]?.enabled && d.id === masters[0]?.devs[1]?.id;
const devOut = (m: MK, d: Dev) => ({ ...d,
priority: shapingOf[0]?.enabled && (d.id === masters[0]?.devs[0]?.id || devTorrent(d)) ? "background" : "normal",
torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length,
ips: m.servers.filter(s => s !== 2).map(s => ({ server_id: s, ip: `10.9.0.${50 + d.id + s * 10}` })) });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
@@ -97,7 +103,7 @@ app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
// Монитор: «установка» и «удаление» Prometheus с ходом процесса (как лог CLI на реальном сервере).
const MON_LOGS = {
install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 15d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."],
install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 31d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."],
remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."],
};
let monJob: { action: "install" | "remove"; start: number } | null = null;
@@ -117,8 +123,52 @@ app.get("/ui/monitor", (_q, r) => {
for (const action of ["install", "remove"] as const) {
app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); });
}
// Перенос панели (internal/ui/migrate.go). Мок — standalone: переносить можно, принимать нельзя;
// MOCK_ROLE=core — наоборот (код «принимается» через 10 с, ещё через 10 с — «перезапуск»);
// MOCK_ROLE=retire — панель уже приняла перенос: готовность к отключению старого сервера.
const MOCK_CORE = process.env.MOCK_ROLE === "core" || process.env.MOCK_ROLE === "retire";
let mv: { state: string; error: string | null; target: string | null; code: { code: string; at: number } | null; movedAt: number } =
{ state: "", error: null, target: null, code: null, movedAt: process.env.MOCK_ROLE === "retire" ? Date.now() : 0 };
const mvRetire = () => {
if (!mv.movedAt) return null;
const t = (Date.now() - mv.movedAt) / 1000; // клиенты и ноды «узнают» адрес со временем
return { since: Math.floor(mv.movedAt / 1000), home: "198.51.100.20:40500",
old: { exists: true, online: true },
devices: { total: 12, fresh: Math.min(12, 7 + Math.floor(t / 15)) }, unused_links: 1,
nodes: [{ id: 1, name: "Frankfurt", online: true, ok: true }, { id: 2, name: "Tokyo", online: false, ok: false },
{ id: 4, name: "Amsterdam", online: true, ok: t > 20 }] };
};
const mvView = () => {
if (mv.code && Date.now() - mv.code.at > 10000) mv = { ...mv, state: "imported", code: null, movedAt: Date.now() };
if (mv.state === "imported" && Date.now() - mv.movedAt > 10000) mv = { ...mv, state: "" };
return { state: mv.state, error: mv.error, target: mv.target,
code: mv.code && { code: mv.code.code, expires_in: 3600 - Math.floor((Date.now() - mv.code.at) / 1000) },
...(mv.state === "imported" ? {} : {
export: MOCK_CORE ? "У этой панели нет своего VPN: переносить её как сервер-ноду нечего" : null,
accept: !MOCK_CORE ? "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
: mv.movedAt ? "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую" : null,
retire: mvRetire() }) };
};
app.get("/ui/migrate", (_q, r) => r.json(mvView()));
app.post("/ui/migrate/code", (q, r) => {
if (!MOCK_CORE) return r.status(409).json({ error: "У этой панели свой VPN" });
mv.code = { code: "awgmove://" + Buffer.from(JSON.stringify({ v: 1, h: q.body?.host ?? "127.0.0.1", p: 8080, k: "mock" })).toString("base64url"), at: Date.now() };
r.json(mvView());
});
app.delete("/ui/migrate/code", (_q, r) => { mv.code = null; r.json(mvView()); });
app.post("/ui/migrate/export", (q, r) => {
const code = String(q.body?.code ?? "");
setTimeout(() => {
if (!code.startsWith("awgmove://ey")) return r.status(400).json({ error: "код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели" });
mv = { ...mv, state: "moved", target: "http://198.51.100.20:31337" };
r.json({ ok: true, target: mv.target });
}, 1500);
});
app.post("/ui/migrate/cancel", (_q, r) => { mv = { ...mv, state: "", error: null }; r.json(mvView()); });
app.delete("/ui/migrate/retire", (_q, r) => { mv.movedAt = 0; r.json(mvView()); });
app.get("/ui/monitor/overview", (q, r) => {
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300] };
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "7d": [604800, 1800], "30d": [2592000, 7200] };
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
const to = Math.floor(Date.now() / step) * step, from = to - sec;
const pts = (f: (i: number, t: number) => number): [number, number][] => Array.from({ length: Math.floor(sec / step) + 1 }, (_, i) => [from + i * step, Math.max(0, f(i, from + i * step))]);
@@ -136,11 +186,13 @@ app.get("/ui/monitor/overview", (q, r) => {
const id = Number(q.query.server ?? 0);
const k = id === 0 ? 0.7 : 0.3;
const down1 = scale(down, k), up1 = scale(up, k);
const used = (a: [number, number][]) => a.reduce((n, [, v]) => n + v, 0) * step / 8; // бит/с × с → байты
if (id === 3) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] } });
r.json({ range: q.query.range ?? "1h", step, from, to,
servers: [{ id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: down1.at(-1)![1], up: up1.at(-1)![1], online: id === 0 ? 7 : 3,
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30 }],
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30,
used_down: used(down1), used_up: used(up1) }],
total: { down: down1, up: up1, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) } });
});
@@ -163,7 +215,8 @@ const shapingView = (id: number) => {
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
const on = settings.enabled;
return {
settings, active: on, congested: on && id === 0, heavy: on && id === 0 ? ["10.9.0.2"] : [],
settings, active: on, congested: on && id === 0,
heavy: on && id === 0 ? ["10.9.0.2", ...(torrentOf[0]?.enabled ? [TORRENT_IP, "10.9.0.12"] : [])] : [],
devices: on ? 7 : 0, rateDown: on ? Math.round(settings.downMbit * 0.9 * 10) / 10 : 0, rateUp: on ? 12.4 : 0,
};
};
@@ -176,10 +229,36 @@ app.put("/api/shaping", (q, r) => {
r.json(shapingView(id));
});
// Блокировка торрентов: на сервере 0 качает пользователь 10.9.0.3 и второе устройство первого мастер-ключа;
// Amsterdam (3) изображает старый nftables без TCP-проверки; Frankfurt (1) — старая нода без маршрута (404).
// Сервер берём прямо из X-Server-Id: srv() подменяет сервер без своих пользователей на 0.
const torrentOf: Record<number, { enabled: boolean; since: number }> = {};
const tsid = (q: express.Request) => Number(q.headers["x-server-id"] ?? 0) || 0;
const TORRENT_IP = "10.9.0.3";
const torrentSeenIp = (id: number, ip: string) => id === 0 && !!torrentOf[0]?.enabled && ip === TORRENT_IP;
const torrentView = (id: number) => {
const t = torrentOf[id] ?? { enabled: false, since: 0 };
const seen = id === 0 && t.enabled
? [{ ip: TORRENT_IP, name: users.find(u => u.ip === TORRENT_IP)?.name ?? "" }, { ip: "10.9.0.12", name: "" }]
: [];
return { settings: { enabled: t.enabled }, active: t.enabled, tcp: t.enabled && id !== 3,
hits: t.enabled && id === 0 ? 1840 + Math.floor((now() - t.since) * 3.7) : 0, seen };
};
app.get("/api/torrent", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(torrentView(tsid(q))));
app.put("/api/torrent", (q, r) => {
const id = tsid(q), b = q.body ?? {};
if (id === 1) return r.status(404).json({ error: "Not found" });
if (typeof b.enabled !== "boolean") return r.status(400).json({ error: "enabled должен быть true/false" });
torrentOf[id] = { enabled: b.enabled, since: b.enabled && !torrentOf[id]?.enabled ? now() : (torrentOf[id]?.since ?? now()) };
r.json(torrentView(id));
});
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
const prioOf = (q: express.Request, ip: string) => (srv(q) === 0 && shapingOf[0]?.enabled && ip === "10.9.0.2" ? "background" : "normal");
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => ({ ...u, priority: prioOf(q, u.ip) })) }));
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
const prioOf = (q: express.Request, ip: string) =>
(srv(q) === 0 && shapingOf[0]?.enabled && (ip === "10.9.0.2" || torrentSeenIp(tsid(q), ip)) ? "background" : "normal");
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => ({ ...u, priority: prioOf(q, u.ip), torrent: torrentSeenIp(tsid(q), u.ip) })) }));
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
app.post("/api/users", (q, r) => {
const users = U_(q);
@@ -215,11 +294,25 @@ app.post("/ui/masterkeys/audit/clean", (_q, r) => {
mockGhost = false; r.json(mockAudit());
});
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
let mkDefaults: number[] | null = null;
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut), defaults: mkDefaults }));
// «Серверы для всех» (sub_admin.go bulkServers): add — у всех, remove — ни у кого.
app.post("/ui/masterkeys/servers", (q, r) => {
const add: number[] = q.body.add ?? [], remove: number[] = q.body.remove ?? [];
if (add.some(x => remove.includes(x))) return r.status(400).json({ error: "Сервер не может быть и у всех, и ни у кого" });
const next = masters.map(m => ({ m, s: [...new Set([...m.servers.filter(x => !remove.includes(x)), ...add])].sort() }));
const empty = next.filter(x => !x.s.length).length;
if (empty) return r.status(409).json({ error: `У ${empty} ключей не останется ни одного сервера — оставьте им хотя бы один` });
const changed = next.filter(x => x.s.join() !== [...x.m.servers].sort().join());
const within = (l: number[], sc: number[]) => l.every(x => sc.includes(x));
const hidden = apiKeys.map(k => ({ id: k.id, label: k.label, keys: changed.filter(x => within(x.m.servers, k.servers) && !within(x.s, k.servers)).length })).filter(h => h.keys);
if (!q.body.dry_run) { changed.forEach(x => { x.m.servers = x.s; }); if ("defaults" in q.body) mkDefaults = q.body.defaults; }
r.json({ changed: changed.length, total: masters.length, hidden });
});
app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3;
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? mkDefaults ?? [0] };
masters.push(m); r.status(201).json(mkOut(m));
});
app.patch("/ui/masterkeys/:id", (q, r) => {
@@ -243,7 +336,14 @@ app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
});
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : r.status(404).json({ error: "Не найден" }); });
// MOCK_MK_FAIL=<id ключа> — /devices этого ключа отвечает 502; MOCK_MK_DOWN=<id сервера> — этот сервер «не ответил».
app.get("/ui/masterkeys/:id/devices", (q, r) => {
const m = find(q.params.id);
if (!m) return r.status(404).json({ error: "Не найден" });
if (process.env.MOCK_MK_FAIL === String(m.id)) return r.status(502).json({ error: "awg-ctrl недоступен" });
const down = Number(process.env.MOCK_MK_DOWN);
r.json({ devices: m.devs.map(d => devOut(m, d)), unreachable: m.servers.filter(s => s === down) });
});
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
+38 -8
View File
@@ -16,11 +16,18 @@ type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ statu
const PROM_URL = process.env.PROM_URL ?? "http://127.0.0.1:9090";
// Шаг подбираем так, чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
// Дальше 30 дней не заглядываем: столько Prometheus хранит (RETENTION в cli/src/monitor.ts).
const RANGES: Record<string, { seconds: number; step: number }> = {
"1h": { seconds: 3600, step: 15 },
"6h": { seconds: 21600, step: 60 },
"24h": { seconds: 86400, step: 300 },
"1h": { seconds: 3600, step: 15 },
"6h": { seconds: 21600, step: 60 },
"24h": { seconds: 86400, step: 300 },
"7d": { seconds: 604800, step: 1800 },
"30d": { seconds: 2592000, step: 7200 },
};
// Окно rate() — не короче шага: иначе на неделе/месяце точка была бы случайной двухминутной выборкой
// из двух часов, а не средним за них. Значения «сейчас» при этом считаются отдельно, по окну 2m.
const RATE_WINDOW = "2m";
const withWindow = (q: string, w: string) => q.split(`[${RATE_WINDOW}]`).join(`[${w}]`);
type Point = [number, number];
type Series = Map<number, { name: string; points: Point[] }>; // server_id → ряд
@@ -43,6 +50,16 @@ const QUERIES = {
} as const;
type Key = keyof typeof QUERIES;
async function instantQuery(query: string, time: number): Promise<Map<number, number>> {
const r = await axios.get(`${PROM_URL}/api/v1/query`, { params: { query, time }, timeout: 8000 });
const out = new Map<number, number>();
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; value: [number, string] }[]) {
const id = Number(m.metric.server_id), v = Number(m.value[1]);
if (Number.isInteger(id) && Number.isFinite(v)) out.set(id, v);
}
return out;
}
async function rangeQuery(query: string, start: number, end: number, step: number): Promise<Series> {
const r = await axios.get(`${PROM_URL}/api/v1/query_range`, {
params: { query, start, end, step }, timeout: 8000,
@@ -116,12 +133,17 @@ export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean })
router.get("/overview", async (req: Request, res: Response) => {
const range = RANGES[String(req.query.range ?? "1h")];
if (!range) { res.status(400).json({ error: "range: 1h, 6h или 24h" }); return; }
if (!range) { res.status(400).json({ error: "range: 1h, 6h, 24h, 7d или 30d" }); return; }
const end = Math.floor(Date.now() / 1000);
const start = end - range.seconds;
try {
const keys = Object.keys(QUERIES) as Key[];
const got = await Promise.all(keys.map(k => rangeQuery(QUERIES[k], start, end, range.step)));
const window = range.step > 120 ? `${range.step}s` : RATE_WINDOW;
const got = await Promise.all(keys.map(k => rangeQuery(withWindow(QUERIES[k], window), start, end, range.step)));
// Окно расширено — «сейчас» с конца ряда было бы средним за шаг; берём мгновенные значения по 2m.
const rated: Key[] = ["down", "up", "cpu"];
const now = window === RATE_WINDOW ? null
: new Map(await Promise.all(rated.map(async k => [k, await instantQuery(QUERIES[k], end)] as const)));
// Вкладка показывает один сервер — тот, что выбран в сервер-баре (?server=<id>).
const only = Number.isInteger(Number(req.query.server)) && req.query.server !== undefined ? Number(req.query.server) : null;
const s = Object.fromEntries(keys.map((k, i) => [k,
@@ -130,19 +152,27 @@ export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean })
// Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна.
const ids = new Set<number>();
for (const k of keys) for (const id of s[k].keys()) ids.add(id);
// Объём за весь период: increase() по тем же монотонным счётчикам интерфейса (сбросы счётчика он учитывает)
const [usedDown, usedUp] = await Promise.all([
instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[${range.seconds}s]))`, end),
instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[${range.seconds}s]))`, end),
]);
const cur = (k: Key, id: number) => now?.has(k) ? (now.get(k)!.get(id) ?? null) : last(s[k].get(id));
const servers = [...ids].sort((a, b) => a - b).map(id => ({
id,
name: (s.scrape_ok.get(id) ?? s.down.get(id) ?? s.online.get(id))?.name ?? String(id),
ok: last(s.scrape_ok.get(id)) === 1,
down: last(s.down.get(id)),
up: last(s.up.get(id)),
down: cur("down", id),
up: cur("up", id),
online: last(s.online.get(id)),
heavy: last(s.heavy.get(id)),
congested: last(s.congested.get(id)) === 1,
cpu: last(s.cpu.get(id)),
cpu: cur("cpu", id),
mem: last(s.mem.get(id)),
cores: last(s.cores.get(id)),
mem_total: last(s.mem_total.get(id)),
used_down: usedDown.get(id) ?? null,
used_up: usedUp.get(id) ?? null,
}));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.3.4",
"version": "0.4.5",
"private": true,
"scripts": {
"dev": "vite",
+15
View File
@@ -417,6 +417,7 @@ body {
}
.node-modal {
width: 100%; max-width: 520px;
max-height: 100%; overflow-y: auto;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
@@ -445,6 +446,9 @@ body {
.ss-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.ss-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: var(--surface-container); border-radius: var(--r-table); padding: 12px; }
.ss-mono { font-family: var(--mono); font-size: 12px; color: var(--neutral-text); word-break: break-all; }
.ss-section { margin: 4px 0 0; font-size: 14px; font-weight: 500; color: var(--on-surface); }
.ss-divider { border: 0; border-top: 1px solid var(--outline-variant); margin: 4px 0; }
.ss-seen { display: flex; flex-wrap: wrap; gap: 6px; }
/* Тёплый чип «внимание»: канал занят, фоновый приоритет */
.chip--warm { background: var(--tertiary-container); color: var(--on-tertiary-container); }
.chip--warm .chip-dot { background: var(--on-tertiary-container); }
@@ -781,3 +785,14 @@ tbody td:last-child { padding-right: 20px; }
.server-pick-name { font-weight: 500; }
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
.server-pick-ip { font-family: var(--mono); margin-left: auto; }
/* Короткий идентификатор под именем (UUID мастер-ключа, ID устройства):
первые символы моноширинным, полностью — в подсказке, по клику копируется. */
.name-stack { display: inline-flex; flex-direction: column; line-height: 1.25; vertical-align: middle; }
.copy-id {
align-self: flex-start;
background: none; border: 0; padding: 0; margin: 0; cursor: pointer;
font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--neutral-text);
}
.copy-id:hover { color: var(--primary); }
.copy-id:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
+18
View File
@@ -149,6 +149,24 @@ export default function App() {
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
}, []);
// Токен протух (24 ч) или отозван, пока панель открыта: запросы вкладок падали с 401, а экран
// оставался прежним. Выходим только по 401 самой панели («Unauthorized» — её JWT не принят):
// 401 от awg-ctrl ноды (внутренняя авторизация) пересылается как есть и сессию не рвёт.
useEffect(() => {
const id = axios.interceptors.response.use(undefined, e => {
if (axios.isAxiosError(e) && e.response?.status === 401 && e.response.data?.error === 'Unauthorized') {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setNodes([]);
setServerId(null);
setServerIdState(null);
showMsg('Сессия истекла — войдите снова');
}
return Promise.reject(e);
});
return () => axios.interceptors.response.eject(id);
}, [showMsg]);
// Восстановить сессию из сохранённого токена
useEffect(() => {
const saved = localStorage.getItem(TOKEN_KEY);
+8 -5
View File
@@ -6,16 +6,19 @@ import type { Priority } from '../lib/shared';
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
// деления канала) чип не показываем: «обычный» там было бы выдумкой.
export default function PriorityChip({ priority }: { priority?: Priority }) {
// деления канала) чип не показываем: «обычный» там было бы выдумкой. С меткой «торренты» устройство
// фоновое независимо от загрузки канала — подсказка говорит об этом.
export default function PriorityChip({ priority, torrent }: { priority?: Priority; torrent?: boolean }) {
if (!priority) return null;
const bg = priority === 'background';
return (
<span
className="tip-wrap"
data-tip={bg
? 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'
: 'Обычный приоритет: канал делится поровну'}
data-tip={!bg
? 'Обычный приоритет: канал делится поровну'
: torrent
? 'Пытается качать торренты — пока висит метка, устройство уступает остальным'
: 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'}
>
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
+97 -11
View File
@@ -3,10 +3,10 @@
// LICENSE file in the root directory of this source tree.
import { useEffect, useState } from 'react';
import axios from 'axios';
import { apiFetch, type NodeInfo, type ShapingStatus } from '../lib/shared';
import { apiFetch, type NodeInfo, type ShapingStatus, type TorrentStatus } from '../lib/shared';
// Настройки сервера: деление канала между устройствами (CAKE). Сами настройки живут в
// awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
// Настройки сервера: деление канала между устройствами (CAKE) и блокировка BitTorrent. Сами
// настройки живут в awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
token: string;
@@ -22,6 +22,9 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [unsupported, setUnsupported] = useState(false);
const [tst, setTst] = useState<TorrentStatus | null>(null);
const [tEnabled, setTEnabled] = useState(false);
const [tUnsupported, setTUnsupported] = useState(false);
const fill = (s: ShapingStatus) => {
setSt(s);
@@ -33,6 +36,20 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
const errText = (e: unknown, fallback: string) =>
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
const fillTorrent = (s: TorrentStatus) => { setTst(s); setTEnabled(s.settings.enabled); };
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/torrent', token)
.then(s => { if (alive) fillTorrent(s as TorrentStatus); })
.catch(e => {
if (!alive) return;
if (axios.isAxiosError(e) && e.response?.status === 404) setTUnsupported(true);
else setError(errText(e, 'Не удалось загрузить настройки'));
});
return () => { alive = false; };
}, [token]);
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/shaping', token)
@@ -55,14 +72,28 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
const downN = Number(down), upN = Number(up);
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
// Отправляем только изменённое: лишний PUT /api/shaping заново ставил бы tc на интерфейс.
const shapingDirty = !!st && (enabled !== st.settings.enabled || auto !== st.settings.auto
|| (Number(down) || 0) !== st.settings.downMbit || (Number(up) || 0) !== st.settings.upMbit);
const torrentDirty = !!tst && tEnabled !== tst.settings.enabled;
const save = async () => {
setBusy(true); setError('');
const done: string[] = [];
try {
const s = await apiFetch('PUT', '/api/shaping', token, {
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
});
fill(s as ShapingStatus);
showMsg(enabled ? 'Деление канала включено' : 'Деление канала выключено');
if (shapingDirty) {
const s = await apiFetch('PUT', '/api/shaping', token, {
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
});
fill(s as ShapingStatus);
done.push(enabled ? 'деление канала включено' : 'деление канала выключено');
}
if (torrentDirty) {
const s = await apiFetch('PUT', '/api/torrent', token, { enabled: tEnabled });
fillTorrent(s as TorrentStatus);
done.push(tEnabled ? 'торренты блокируются' : 'блокировка торрентов выключена');
}
if (done.length) showMsg(done.join(', ').replace(/^./, c => c.toUpperCase()));
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
finally { setBusy(false); }
};
@@ -70,7 +101,8 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">Деление канала: {node.name}</h3>
<h3 className="node-modal-title">Настройки сервера: {node.name}</h3>
<h4 className="ss-section">Деление канала</h4>
{unsupported ? (
<p className="node-modal-hint">
@@ -135,11 +167,65 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
</>
)}
<hr className="ss-divider" />
<h4 className="ss-section">Торренты</h4>
{tUnsupported ? (
<p className="node-modal-hint">
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
</p>
) : (
<>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Блокировать BitTorrent</span>
<span className="node-modal-hint">
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
трекеров большинство торрентов не стартуют. Остальной трафик не режется.
</span>
<span className="node-modal-hint">
{enabled
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
</span>
</span>
<input type="checkbox" checked={tEnabled} disabled={!tst || busy} onChange={e => setTEnabled(e.target.checked)} />
</label>
{tst?.settings.enabled && (
<div className="ss-status">
<span className={`chip ${tst.active ? 'chip--online' : 'chip--offline'}`}>
<span className="chip-dot" />{tst.active ? 'работает' : 'не применено'}
</span>
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
{tst.active && !tst.tcp && (
<span className="node-modal-hint">
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
</span>
)}
{tst.error && <span className="node-modal-hint">{tst.error}</span>}
{tst.seen.length > 0 && (
<>
<span className="node-modal-hint">Пытались качать за последние 10 минут:</span>
<span className="ss-seen">
{tst.seen.map(p => (
<span key={p.ip} className="chip chip--error">
<span className="chip-dot" />{p.name ? `${p.name} · ${p.ip}` : p.ip}
</span>
))}
</span>
</>
)}
</div>
)}
</>
)}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
{!unsupported && (
<button className="btn btn--primary" onClick={save} disabled={!st || busy || !valid}>
{!(unsupported && tUnsupported) && (
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
Сохранить
</button>
)}
+14
View File
@@ -0,0 +1,14 @@
// 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.
// Пометка «торренты»: устройство за последние 10 минут пыталось открыть торрент-соединение, и оно
// было заблокировано. Без флага (блокировка выключена или awg-ctrl старше) чип не показываем.
export default function TorrentChip({ torrent }: { torrent?: boolean }) {
if (!torrent) return null;
return (
<span className="tip-wrap" data-tip="За последние 10 минут пыталось качать торренты: соединения заблокированы, а при делении канала устройство в фоновом приоритете">
<span className="chip chip--error"><span className="chip-dot" />торренты</span>
</span>
);
}
+7
View File
@@ -102,9 +102,16 @@ export const IcoPulse = () => (
</svg>
);
export const IcoMove = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="8" height="16" rx="2"/><rect x="14" y="4" width="8" height="16" rx="2"/><path d="M7 12h10"/><path d="m14 9 3 3-3 3"/>
</svg>
);
export const ICONS: Record<string, () => JSX.Element> = {
users: IcoUsers,
key: IcoKey,
shield: IcoShield,
pulse: IcoPulse,
move: IcoMove,
};
+11
View File
@@ -19,6 +19,7 @@ export interface User {
rx?: number;
tx?: number;
priority?: Priority; // нет у awg-ctrl старше деления канала
torrent?: boolean; // пытался качать торренты за последние 10 минут (при включённой блокировке)
}
export interface ServerInfo {
name: string;
@@ -79,6 +80,16 @@ export interface ShapingStatus {
rateUp: number;
}
// Блокировка BitTorrent (GET/PUT /api/torrent на awg-ctrl сервера).
export interface TorrentStatus {
settings: { enabled: boolean };
active: boolean; // nft-таблица реально стоит
tcp: boolean; // проверяется и TCP-рукопожатие (нужен свежий nftables)
hits: number; // заблокированных попыток с последнего применения
seen: { ip: string; name: string }[]; // кто пытался за последние 10 минут
error?: string;
}
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
export function cmpVersion(a: string, b: string): number {
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
@@ -0,0 +1,125 @@
// 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, useMemo } from 'react';
import { apiFetch, genLabel, type NodeInfo } from '../../lib/shared';
// «Серверы для всех»: один набор правок на все мастер-ключи (POST /ui/masterkeys/servers).
// У каждого сервера — «у всех», «ни у кого» или «как есть» (если он стоит только у части ключей).
// Перед применением сервер считает, сколько ключей изменится и какие ключи API потеряют доступ к
// части из них (ключ виден API-ключу, только пока все его серверы в области).
type Pick = 'all' | 'none' | 'keep';
interface Preview { changed: number; total: number; hidden: { id: number; label: string; keys: number }[] }
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function BulkServersModal({ token, nodes, keys, defaults, onClose, onDone }: {
token: string;
nodes: NodeInfo[];
keys: { servers: number[] }[]; // живые мастер-ключи
defaults: number[] | null;
onClose: () => void;
onDone: (changed: number) => void;
}) {
const total = keys.length;
const count = (id: number) => keys.filter(k => k.servers.includes(id)).length;
const initial = (id: number): Pick => { const n = count(id); return n === total && total > 0 ? 'all' : n === 0 ? 'none' : 'keep'; };
const [picks, setPicks] = useState<Record<number, Pick>>(() => Object.fromEntries(nodes.map(n => [n.id, initial(n.id)])));
const [forNew, setForNew] = useState(true);
const [preview, setPreview] = useState<Preview | null>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const { add, remove } = useMemo(() => ({
add: nodes.filter(n => picks[n.id] === 'all' && count(n.id) < total).map(n => n.id),
remove: nodes.filter(n => picks[n.id] === 'none' && count(n.id) > 0).map(n => n.id),
}), [picks, nodes, keys]);
const all = nodes.filter(n => picks[n.id] === 'all').map(n => n.id);
const sameDefaults = JSON.stringify([...all].sort()) === JSON.stringify([...(defaults ?? [])].sort());
const body = { add, remove, ...(forNew && !sameDefaults ? { defaults: all.length ? all : null } : {}) };
const nothing = !add.length && !remove.length && !('defaults' in body);
useEffect(() => {
if (!add.length && !remove.length) { setPreview(null); setError(''); return; }
let live = true;
apiFetch('POST', '/ui/masterkeys/servers', token, { add, remove, dry_run: true })
.then(p => { if (live) { setPreview(p); setError(''); } })
.catch(e => { if (live) { setPreview(null); setError(errText(e)); } });
return () => { live = false; };
}, [token, add, remove]);
useEffect(() => {
const h = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const apply = async () => {
setBusy(true);
try { const r = await apiFetch('POST', '/ui/masterkeys/servers', token, body); onDone(r.changed ?? 0); }
catch (e) { setError(errText(e)); }
finally { setBusy(false); }
};
const option = (id: number, p: Pick, label: string) => (
<button type="button" className={`bs-opt${picks[id] === p ? ' bs-opt--on' : ''}`} aria-pressed={picks[id] === p}
onClick={() => setPicks(prev => ({ ...prev, [id]: p }))}>{label}</button>
);
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal bs-modal" role="dialog" aria-modal="true" aria-label="Серверы для всех ключей" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">Серверы для всех ключей</h3>
<p className="node-modal-hint">
Применится ко всем мастер-ключам сразу ({total}). Пользователям ничего делать не нужно: приложение получит
новый набор серверов при следующем обновлении подписки, ссылки и ключи остаются прежними.
</p>
<div className="bs-list">
{nodes.map(n => {
const c = count(n.id), partial = c > 0 && c < total;
return (
<div className="bs-row" key={n.id}>
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
<span className="chip-dot" />
</span>
<span className="bs-name">
<span className="server-pick-name">{n.health?.server || n.name}</span>
<span className="bs-sub">
{n.health?.ip && <code className="server-pick-ip">{n.health.ip}</code>}
{n.health?.gen && <span>{genLabel(n.health.gen)}</span>}
<span>сейчас у {c} из {total}</span>
</span>
</span>
<span className="bs-opts" role="group" aria-label={`Сервер ${n.name}`}>
{option(n.id, 'all', 'У всех')}
{partial && option(n.id, 'keep', 'Как есть')}
{option(n.id, 'none', 'Ни у кого')}
</span>
</div>
);
})}
</div>
<label className="bs-check">
<input type="checkbox" checked={forNew} onChange={e => setForNew(e.target.checked)} />
<span>Новым ключам — серверы «У всех»{all.length ? '' : ' (не выбрано ни одного — как раньше: все подключённые)'}</span>
</label>
{preview && (
<p className="node-modal-hint">Изменится ключей: {preview.changed} из {preview.total}.</p>
)}
{preview?.hidden.map(h => (
<p className="mk-notice" key={h.id}>
Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области.
Расширьте его область на вкладке «API-ключи», если он ими управляет.
</p>
))}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
<button className="btn btn--outline" onClick={onClose}>Отмена</button>
<button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}>
{busy ? 'Применяем…' : 'Применить'}
</button>
</div>
</div>
</div>
);
}
+171 -58
View File
@@ -1,12 +1,14 @@
// 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 { Fragment, useState, useEffect, useCallback } from 'react';
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import BulkServersModal from './BulkServersModal';
import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
@@ -16,8 +18,9 @@ import './masterkeys.css';
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
interface Device {
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority; torrent?: boolean;
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
}
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
@@ -31,6 +34,22 @@ const platformLabel = (d: Device): string =>
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
const statusChip = (d: Device) => (
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{d.online ? 'онлайн' : d.lastHandshake ? timeAgo(d.lastHandshake) + ' назад' : 'никогда'}
</span>
);
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом;
// other — адрес с другого сервера, его надо подписать, иначе его примут за адрес выбранного.
const ipPick = (d: Device): { ip: string; serverId?: number; other: boolean } => {
const own = d.ips?.find(x => x.server_id === getServerId());
const pick = own ?? d.ips?.[0];
return { ip: pick?.ip ?? '—', serverId: pick?.server_id, other: !own && !!pick };
};
export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<MasterKey[]>([]);
const [enabled, setEnabled] = useState(true);
@@ -42,18 +61,42 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
const [limitEdit, setLimitEdit] = useState('');
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
const [creating, setCreating] = useState(false);
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
const creatingRef = useRef(false);
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
const [defaults, setDefaults] = useState<number[] | null>(null); // серверы новых ключей из «Серверы для всех»
const [bulk, setBulk] = useState(false);
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const d = await apiFetch('GET', '/ui/masterkeys', token);
const list: MasterKey[] = d.keys ?? [];
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
if (seq !== loadSeq.current) return;
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setDefaults(d.defaults ?? null);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
const all = await Promise.all(list.filter(k => !k.deleting).map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
const live = list.filter(k => !k.deleting);
const res = await Promise.allSettled(live.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token)));
if (seq !== loadSeq.current) return;
const bad = new Set<number>(), unreachable: Record<number, number[]> = {};
const fresh: Record<number, Device[]> = {};
res.forEach((r, i) => {
const id = live[i].id;
if (r.status === 'fulfilled') {
fresh[id] = r.value.devices ?? [];
if (r.value.unreachable?.length) unreachable[id] = r.value.unreachable;
} else bad.add(id);
});
// Не загрузилось — оставляем прежний список (устаревший лучше пустого), но помечаем.
setDevs(prev => Object.fromEntries(live.flatMap(k =>
fresh[k.id] ? [[k.id, fresh[k.id]]] : prev[k.id] ? [[k.id, prev[k.id]]] : [])));
setFailed(bad); setDown(unreachable);
} catch { if (seq === loadSeq.current) showMsg('Ошибка загрузки мастер-ключей'); }
}, [token, showMsg]);
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
@@ -63,16 +106,20 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [load, showMsg]);
const create = useCallback(async () => {
if (!label.trim()) return;
await act(async () => {
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
if (!label.trim() || creatingRef.current) return;
creatingRef.current = true; setCreating(true);
try { await act(async () => {
// Новый ключ — с серверами из «Серверы для всех» (их подставит панель), а не задано — со всеми,
// которые уже подключались; набор потом правится в настройках ключа.
const ready = nodes.filter(n => !n.pending).map(n => n.id);
await apiFetch('POST', '/ui/masterkeys', token, {
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
label: label.trim(), device_limit: Number(limit), ...(!defaults && ready.length ? { servers: ready } : {}),
});
setLabel('');
}, 'Мастер-ключ создан');
}, [label, limit, nodes, token, act]);
}, 'Мастер-ключ создан'); }
finally { creatingRef.current = false; setCreating(false); }
}, [label, limit, nodes, defaults, token, act]);
const live = useMemo(() => keys.filter(k => !k.deleting), [keys]);
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
@@ -105,15 +152,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [fetchLink, showMsg]);
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
// поэтому сперва спросить и назвать, кого именно.
// поэтому сперва спросить и назвать, кого именно. Список устройств не загрузился — считаем по
// счётчику ключа и имён не называем, но спросить всё равно надо.
const saveLimit = useCallback(async (k: MasterKey) => {
const limit = Number(limitEdit);
const list = devs[k.id] ?? [];
const extra = list.length - limit;
const list = devs[k.id];
const have = list?.length ?? k.devices;
const extra = have - limit;
if (Number.isInteger(limit) && extra > 0) {
const names = [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
.map(d => `«${d.device_name || d.id}»`).join(', ');
if (!confirm(`Лимит ${limit} меньше, чем устройств (${list.length}). Будут отвязаны подключившиеся последними: ${names}. Продолжить?`)) return;
const who = list
? [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
.map(d => `«${d.device_name || d.id}»`).join(', ')
: `${extra} шт.`;
if (!confirm(`Лимит ${limit} меньше, чем устройств (${have}). Будут отвязаны подключившиеся последними: ${who}. Продолжить?`)) return;
}
try {
const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit });
@@ -253,17 +304,60 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div>
);
// UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка),
// полностью — в подсказке, по клику копируется.
const uuidCell = (k: MasterKey) => (
<span className="tip-wrap mk-uuid-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
<button className="mk-uuid-btn" aria-label={`Скопировать UUID ${k.uuid}`}
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
<code className="mk-device-id">{k.uuid}</code>
</button>
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
// по клику копируется.
const nameCell = (k: MasterKey) => (
<span className="name-stack">
{k.label}
<span className="tip-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать UUID ${k.uuid}`}
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
{k.uuid.slice(0, 8)}…
</button>
</span>
</span>
);
// Данные по ключу неполные: устройства не загрузились или сервер не ответил на статистику.
const dataFlag = (k: MasterKey) => {
if (failed.has(k.id)) return <span className="mk-flag" title="Список устройств не загрузился — показан последний известный"> · устройства не загрузились</span>;
const d = down[k.id];
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
};
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
const summary = (list: Device[]) => {
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>;
const on = list.filter(d => d.online).length;
const last = Math.max(...list.map(d => d.lastHandshake || 0));
const torrent = list.some(d => d.torrent);
const prio = list.some(d => d.priority === 'background') ? 'background' : list.find(d => d.priority)?.priority;
return (
<>
<td>
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
</span>
</td>
<td><span className="user-chips"><PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} /></span></td>
<td colSpan={2}></td>
<td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td>
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
</>
);
};
// Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного.
const ipCell = (d: Device) => {
const all = d.ips ?? [];
const { ip, serverId, other } = ipPick(d);
const text = <code className="mk-ip">{ip}{other && serverId !== undefined ? ` · ${serverName(nodes, serverId)}` : ''}</code>;
return all.length > 1
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
: text;
};
const caret = (k: MasterKey) => (
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
@@ -278,13 +372,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
</span>
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
<span className="mk-device-sub">{platformLabel(d)}
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
</div>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
<PriorityChip priority={d.priority} />
{statusChip(d)}
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
<div className="mk-device-ip">
<IcoGlobe />
{ipCell(d)}
<PriorityChip priority={d.priority} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
</div>
<div className="mk-device-traffic">
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
@@ -309,7 +407,9 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств"
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={create}><IcoPlus /> Создать ключ</button>
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
<button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length}
title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button>
</div>
</div>
@@ -318,8 +418,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<table className="mk-table">
<thead>
<tr>
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th>
<th>↓ rx</th><th>↑ tx</th><th>Был</th><th>Устройства</th><th></th>
<th>Имя</th><th>Статус</th><th>Приоритет</th><th>IP</th><th>Платформа</th>
<th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
</tr>
</thead>
<tbody>
@@ -328,39 +428,46 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
) : keys.map(k => {
if (k.deleting) return (
<tr key={k.id} className="mk-tr-master mk-last">
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
<td className="td-mono">{uuidCell(k)}</td>
<td colSpan={7}>{deletingChip(k)}</td>
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
<td colSpan={8}>{deletingChip(k)}</td>
</tr>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const open = !folded.has(k.id);
return (
<Fragment key={k.id}>
<tr className="mk-tr-master">
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
<td className="td-mono">{uuidCell(k)}</td>
<td colSpan={5}></td>
<td>{meter(k, list.length)}</td>
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
{summary(list)}
<td>{meter(k, count)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td>
</tr>
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
{open && list.map((d, i) => (
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
<td><span className="mk-indent">{d.device_name || 'Устройство'}</span></td>
<td className="td-mono"><code className="mk-device-id" title={d.device_id}>{d.device_id}</code></td>
<td>{platformLabel(d)}</td>
<td>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
<span className="mk-indent">
<span className="name-stack">
{d.device_name || 'Устройство'}
<span className="tip-wrap" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
{d.device_id.slice(0, 8)}…
</button>
</span>
</span>
</span>
</td>
<td className="td-mono">{bytes(d.rx)}</td>
<td className="td-mono">{bytes(d.tx)}</td>
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td>
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}
</td>
<td>{statusChip(d)}</td>
<td><span className="user-chips"><PriorityChip priority={d.priority} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
<td className="td-mono">{ipCell(d)}</td>
<td>{platformLabel(d)}</td>
<td>{bytes(d.rx)}</td>
<td>{bytes(d.tx)}</td>
<td></td>
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr>
@@ -391,15 +498,16 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</li>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const isFolded = folded.has(k.id);
return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
{caret(k)}
<IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, list.length)}
<span className="mk-master-label">{k.label}{dataFlag(k)}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, count)}
{actions(k)}
</div>
{tools === k.id && toolbox(k)}
@@ -417,6 +525,11 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</ul>
)}
{bulk && (
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
)}
{modal && (
<div className="qr-backdrop" onClick={() => setModal(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
+40 -7
View File
@@ -26,13 +26,6 @@ LICENSE file in the root directory of this source tree.
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
.mk-tr-master:hover td { background: var(--surface-container); }
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
/* UUID ключа в строке: выглядит как ID устройства, по клику копирует */
.mk-uuid-btn {
background: none; border: 0; padding: 0; margin: 0;
cursor: pointer; font: inherit; font-weight: 400; color: inherit; line-height: inherit;
}
.mk-uuid-btn:hover .mk-device-id { color: var(--primary); }
.mk-uuid-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
.mk-master-uuid {
display: block;
font-family: var(--mono); font-size: 12px; font-weight: 400;
@@ -46,6 +39,9 @@ LICENSE file in the root directory of this source tree.
.mk-tr-tools:hover { background: transparent; }
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: 12px; }
/* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */
.mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; }
.mk-ip { font-family: var(--mono); }
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
@@ -210,6 +206,13 @@ LICENSE file in the root directory of this source tree.
}
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
.mk-device-ip {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
width: 100%;
color: var(--neutral-text);
}
.mk-device-ip .mk-ip { font-size: 13px; }
.mk-device-ip .tip-wrap { width: auto; }
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
.mk-metric { display: inline-flex; gap: 4px; }
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
@@ -276,3 +279,33 @@ LICENSE file in the root directory of this source tree.
.mk-free { padding: 12px; font-size: 13px; }
}
/* «Серверы для всех» (BulkServersModal) */
.bs-modal { max-width: 620px; }
.bs-list { display: flex; flex-direction: column; gap: 8px; }
.bs-row {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container); border-radius: 12px; padding: 8px 8px 8px 14px;
font-size: 13px; color: var(--on-surface);
}
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--neutral-text); }
.bs-sub .server-pick-ip { margin-left: 0; }
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
.bs-opt {
min-height: 36px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill);
cursor: pointer; white-space: nowrap;
}
.bs-opt:hover { background: var(--state-primary); }
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.bs-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--on-surface); cursor: pointer; }
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
@media (max-width: 640px) {
.bs-row { flex-wrap: wrap; }
.bs-opts { width: 100%; }
.bs-opt { flex: 1; }
}
.bs-modal .btn:disabled { opacity: .5; cursor: default; }
+229
View File
@@ -0,0 +1,229 @@
// 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 } from 'react';
import { apiFetch, copyText, type PageProps } from '../../lib/shared';
import { IcoCopy } from '../../components/icons';
import './migrate.css';
// Вкладка «Перенос»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
type MoveState = '' | 'sending' | 'moved' | 'uncertain' | 'imported';
// Новая панель после приёма: кто уже знает её адрес — тогда старый сервер можно выключать.
interface Retire {
since: number;
home: string | null; // адрес хаба для нод; null — не задан SUB_HOST
old: { exists: boolean; online: boolean };
devices: { total: number; fresh: number }; // fresh — получили конфиг после переноса
unused_links: number; // мастер-ключи без устройств: в ссылке старый адрес
nodes: { id: number; name: string; online: boolean; ok: boolean }[];
}
interface MigrateInfo {
state: MoveState;
error: string | null;
target: string | null; // старая: адрес новой панели
code: { code: string; expires_in: number } | null;
export?: string | null; // null — можно, строка — почему нельзя
accept?: string | null;
retire?: Retire | null;
}
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function MigratePage({ token, showMsg }: PageProps) {
const [info, setInfo] = useState<MigrateInfo | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
try { setInfo(await apiFetch('GET', '/ui/migrate', token)); }
catch { /* панель перезапускается — следующий опрос покажет */ }
}, [token]);
useEffect(() => { if (token) load(); }, [token, load]);
// Пока висит код — ждём, когда старая панель пришлёт архив; после приёма панель
// перезапускается, и страница перечитывается уже с перенесённой базой.
const waiting = !!info?.code || info?.state === 'imported' || !!info?.retire;
useEffect(() => {
if (!waiting) return;
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [waiting, load]);
useEffect(() => {
if (info?.state !== 'imported' || info.error) return;
const t = setTimeout(() => window.location.reload(), 8000);
return () => clearTimeout(t);
}, [info?.state, info?.error]);
const call = async (method: string, path: string, body?: object) => {
setBusy(true); setError('');
try { setInfo(await apiFetch(method, `/ui/migrate${path}`, token, body)); }
catch (e) { setError(errText(e)); }
finally { setBusy(false); }
};
const send = async () => {
if (!window.confirm('Перенести панель? Здесь она остановится, а этот сервер станет нодой новой панели.')) return;
setBusy(true); setError('');
try {
await apiFetch('POST', '/ui/migrate/export', token, { code: code.trim() });
await load();
} catch (e) { setError(errText(e)); await load(); }
finally { setBusy(false); }
};
if (!info) return null;
if (info.state === 'moved') return (
<section className="mv-box">
<span className="chip chip--online mv-self"><span className="chip-dot" />Панель перенесена</span>
<p className="mv-text">
Новая панель: <a href={info.target ?? '#'}>{info.target}</a>. Через несколько секунд этот сервер станет её
нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать,
а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.
</p>
{info.target && <a className="btn btn--primary mv-self" href={info.target}>Открыть новую панель</a>}
</section>
);
if (info.state === 'imported') return (
<section className="mv-box">
<span className={`chip chip--${info.error ? 'error' : 'online'} mv-self`}><span className="chip-dot" />
{info.error ? 'Приём прерван' : 'Панель принята'}</span>
<p className="mv-text">
{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}
</p>
</section>
);
const r = info.retire;
const retireBox = r && (() => {
const devicesOk = r.devices.fresh >= r.devices.total;
const nodesOk = r.nodes.every(n => n.ok);
const ready = devicesOk && nodesOk && !!r.home;
const chip = (ok: boolean, text: string, offline = false) => (
<span className={`chip chip--${ok ? 'online' : offline ? 'offline' : 'warm'}`}><span className="chip-dot" />{text}</span>
);
return (
<section className="mv-box" key="retire">
<h4 className="mv-title">Отключение старого сервера</h4>
<p className="mv-text">
{r.old.exists
? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его
можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними.</>
: <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.</>}
</p>
<div className="mv-checks">
<div className="mv-check">
{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)}
<span>устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}</span>
</div>
{r.nodes.map(n => (
<div className="mv-check" key={n.id}>
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)}
<span>нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}</span>
</div>
))}
{r.unused_links > 0 && (
<div className="mv-check">
{chip(false, String(r.unused_links), true)}
<span>мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на
вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же</span>
</div>
)}
</div>
{!r.home && <p className="mv-notice">Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.</p>}
<ol className="mv-steps">
<li>Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.</li>
<li>Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.</li>
<li>Удалите сервер «#0» и выключите старую машину.</li>
</ol>
{!r.old.exists && (
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('DELETE', '/retire')}>Готово, скрыть</button>
)}
</section>
);
})();
const canExport = info.export === null, canAccept = info.accept === null;
const exportBox = (
<section className="mv-box" key="export">
<h4 className="mv-title">Перенести эту панель на другой сервер</h4>
{canExport ? (
<>
<p className="mv-text">
Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели.
Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.
</p>
<ol className="mv-steps">
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
<li>Там на вкладке «Перенос» нажмите «Получить код».</li>
<li>Вставьте код сюда и нажмите «Перенести».</li>
</ol>
{info.state === 'uncertain' ? (
<>
<p className="mv-notice">{info.error}</p>
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('POST', '/cancel')}>
Отменить перенос
</button>
</>
) : (
<div className="mv-row">
<input className="field" placeholder="awgmove://…" value={code} disabled={busy}
onChange={e => setCode(e.target.value)} />
<button className="btn btn--primary" disabled={busy || !code.trim().startsWith('awgmove://')} onClick={send}>
{busy ? 'Переносим…' : 'Перенести'}
</button>
</div>
)}
</>
) : <p className="mv-text mv-muted">{info.export}</p>}
</section>
);
const acceptBox = (
<section className="mv-box" key="accept">
<h4 className="mv-title">Принять панель с другого сервера</h4>
{canAccept ? (
info.code ? (
<>
<p className="mv-text">
Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
</p>
<code className="node-modal-cmd">{info.code.code}</code>
<div className="mv-row mv-row--end">
<button className="btn btn--tonal" onClick={() => copyText(info.code!.code).then(() => showMsg('Код скопирован'))}>
<IcoCopy /> Скопировать
</button>
<button className="btn btn--outline" disabled={busy} onClick={() => call('DELETE', '/code')}>Отозвать</button>
</div>
<p className="mv-text mv-muted">Ждём архив от старой панели…</p>
</>
) : (
<>
<p className="mv-text">
Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней
нодой #0. Логин и пароль останутся этой панели.
</p>
<button className="btn btn--primary mv-self" disabled={busy}
onClick={() => call('POST', '/code', { host: window.location.hostname })}>
Получить код
</button>
</>
)
) : <p className="mv-text mv-muted">{info.accept}</p>}
</section>
);
return (
<>
{/* Доступное действие — первым. */}
{retireBox}
{canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]}
{error && <p className="mv-notice">{error}</p>}
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "migrate",
"label": "Перенос",
"icon": "move",
"group": "server",
"order": 1.9,
"chrome": {
"serverBar": false,
"restart": false
}
}
+26
View File
@@ -0,0 +1,26 @@
/*
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.
*/
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.mv-title { margin: 0; font-size: 14px; font-weight: 500; }
.mv-text { margin: 0; font-size: 14px; line-height: 1.5; }
.mv-muted { font-size: 13px; color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg);
border-radius: 16px; padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 640px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-box .btn:disabled { opacity: .5; cursor: default; }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: 14px; line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
+60 -27
View File
@@ -21,12 +21,13 @@ interface Overview {
servers: {
id: number; name: string; ok: boolean; down: number | null; up: number | null; online: number | null; heavy: number | null; congested: boolean;
cpu: number | null; mem: number | null; cores: number | null; mem_total: number | null;
used_down: number | null; used_up: number | null; // байт за весь период
}[];
total: Series;
}
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts }
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа']];
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней']];
const nf = (v: number, d = 1) => v.toLocaleString('ru-RU', { maximumFractionDigits: d });
// Скорость в битах/с → «12,4 Мбит/с»
@@ -72,6 +73,12 @@ function JobProgress({ job, starting }: { job: Status['job']; starting: boolean
);
}
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
// Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт
function vol(b: number): string {
if (b >= 1e12) return `${nf(b / 1e12, 2)} ТБ`;
if (b >= 1e9) return `${nf(b / 1e9, 1)} ГБ`;
return `${nf(b / 1e6, 0)} МБ`;
}
export default function MonitorPage({ token, showMsg }: PageProps) {
const [status, setStatus] = useState<Status | null>(null);
@@ -130,7 +137,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
}, [pending, status?.job?.running, loadStatus]);
const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 15 дней) сотрутся. Панель продолжит отдавать /metrics.')) return;
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
@@ -203,7 +210,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<span className="chip chip--online"><span className="chip-dot" />работает</span>
</div>
<p className="mon-hint">
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 15 дней. Удаление остановит его, снимет
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет
пакет и сотрёт накопленные данные.
</p>
{status.can_install ? (
@@ -228,9 +235,11 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
const one = ov?.servers[0];
const data = ov?.total ?? null;
const cur = data && {
down: lastOf(data.down), up: lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy),
// Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
down: one?.down ?? lastOf(data.down), up: one?.up ?? lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy),
busy: !!one?.congested,
};
const used = one?.used_down != null && one.used_up != null ? one.used_down + one.used_up : null;
// Одна единица на всю ось (Мбит/с, а не «50 Мбит» у каждой риски): выбирается по максимуму ряда
const peak = data ? Math.max(0, ...data.down.map(p => p[1]), ...data.up.map(p => p[1])) : 0;
const unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
@@ -251,33 +260,57 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<div className="mon-tiles">
<div className="mon-tile">
<span className="mon-tile-label">Загрузка сейчас</span>
<span className="mon-tile-value">{cur?.down != null ? rate(cur.down) : '—'}</span>
<span className="mon-tile-label">Трафик сейчас</span>
{/* Стрелки окрашены как серии графика «Трафик» ниже */}
<div className="mon-tile-rates">
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dir" style={{ color: 'var(--series-1)' }} role="img" aria-label="загрузка">↓</span>
{cur?.down != null ? rate(cur.down) : '—'}
</span>
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dir" style={{ color: 'var(--series-2)' }} role="img" aria-label="отдача">↑</span>
{cur?.up != null ? rate(cur.up) : '—'}
</span>
</div>
<span className="mon-tile-value mon-tile-rate mon-tile-total">
{used != null ? vol(used) : '—'}
<span className="mon-tile-sub">потрачено за {RANGES.find(([id]) => id === range)?.[1]}</span>
</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Отдача сейчас</span>
<span className="mon-tile-value">{cur?.up != null ? rate(cur.up) : '—'}</span>
<span className="mon-tile-label">Устройства</span>
{/* Точки окрашены как серии графика «Устройства» ниже */}
<div className="mon-tile-rates">
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dot" style={{ background: 'var(--series-1)' }} aria-hidden="true" />
{cur?.online != null ? nf(cur.online, 0) : '—'}
<span className="mon-tile-sub">онлайн</span>
</span>
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dot" style={{ background: 'var(--series-2)' }} aria-hidden="true" />
{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}
<span className="mon-tile-sub">в фоне · {cur?.busy ? 'канал занят' : 'канал свободен'}</span>
</span>
</div>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Устройств онлайн</span>
<span className="mon-tile-value">{cur?.online != null ? nf(cur.online, 0) : '—'}</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">В фоне</span>
<span className="mon-tile-value">{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}</span>
<span className="mon-tile-sub">{cur?.busy ? 'канал занят' : 'канал свободен'}</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Процессор</span>
<span className="mon-tile-value">{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}</span>
<Bar v={one?.cpu} label="Загрузка процессора" />
<span className="mon-tile-sub">{one?.cores ? `${nf(one.cores, 0)} ядер` : ''}</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Память</span>
<span className="mon-tile-value">{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}</span>
<Bar v={one?.mem} label="Занято оперативной памяти" />
<span className="mon-tile-sub">{one?.mem_total ? `из ${gb(one.mem_total)}` : ''}</span>
<span className="mon-tile-label">Нагрузка</span>
<div className="mon-tile-rates">
<div className="mon-tile-meter">
<span className="mon-tile-value mon-tile-rate">
{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}
<span className="mon-tile-sub">процессор{one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}</span>
</span>
<Bar v={one?.cpu} label="Загрузка процессора" />
</div>
<div className="mon-tile-meter">
<span className="mon-tile-value mon-tile-rate">
{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}
<span className="mon-tile-sub">память{one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}</span>
</span>
<Bar v={one?.mem} label="Занято оперативной памяти" />
</div>
</div>
</div>
</div>
+6
View File
@@ -56,6 +56,12 @@ LICENSE file in the root directory of this source tree.
.mon-tile-label { font-size: 12px; color: var(--neutral-text); }
.mon-tile-value { font-size: 20px; font-weight: 500; color: var(--on-surface); }
.mon-tile-sub { font-size: 12px; color: var(--neutral-text); }
.mon-tile-rates { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
.mon-tile-rate { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; white-space: nowrap; }
.mon-tile-dir { font-size: 16px; }
.mon-tile-total { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--outline-variant); }
.mon-tile-meter { display: flex; flex-direction: column; gap: 4px; }
.mon-tile-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
.mon-charts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 16px; }
@media (min-width: 1000px) {
+5 -3
View File
@@ -9,6 +9,7 @@ import {
} from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import './users.css';
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
@@ -29,7 +30,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const data = await apiFetch('GET', '/api/users/stats', tok);
setUsers(prev => prev.map(u => {
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority } : u;
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority, torrent: s.torrent } : u;
}));
} catch { /* silent */ }
}, []);
@@ -187,7 +188,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
: 'никогда'}
</span>
</td>
<td><PriorityChip priority={u.priority} /></td>
<td><span className="user-chips"><PriorityChip priority={u.priority} torrent={u.torrent} /><TorrentChip torrent={u.torrent} /></span></td>
<td>
{u.name}
{u.key_gen !== serverGen && (
@@ -254,7 +255,8 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<div className="user-card-ip">
<IcoGlobe />
<span className="user-card-ip-text">{u.ip}</span>
<PriorityChip priority={u.priority} />
<PriorityChip priority={u.priority} torrent={u.torrent} />
<TorrentChip torrent={u.torrent} />
</div>
<div className="user-card-metrics">
<div className="metric-block">
+4
View File
@@ -47,6 +47,7 @@ LICENSE file in the root directory of this source tree.
}
.user-card-ip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--neutral-text);
@@ -78,3 +79,6 @@ LICENSE file in the root directory of this source tree.
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
.user-gen { width: auto; }
}
/* Чипы приоритета и торрентов в одной ячейке */
.user-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.3.4",
"version": "0.4.5",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+1 -1
View File
@@ -20,7 +20,7 @@ export const MONITOR_HASH_FILE = `${AWG_DIR}/monitor_token.sha256`;
const DEFAULTS_FILE = "/etc/default/prometheus";
const MARKER = "# managed by forgetting";
const LISTEN = "127.0.0.1:9090";
const RETENTION = "15d";
const RETENTION = "31d"; // с запасом на вид «30 дней» во вкладке «Монитор»
const log = (s: string) => console.log(` ${s}`);
function fail(s: string): never { console.error(`\n ✗ ${s}\n`); process.exit(1); }
+48 -5
View File
@@ -1,19 +1,32 @@
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent и команды оператора.
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent / relay и команды оператора.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/maeneko/forgetting/internal/agent"
"github.com/maeneko/forgetting/internal/cli"
"github.com/maeneko/forgetting/internal/ctrl"
"github.com/maeneko/forgetting/internal/relay"
"github.com/maeneko/forgetting/internal/ui"
)
// version — из сборки релиза (-ldflags "-X main.version=…").
var version = "dev"
func main() {
// Без аргументов или с командой оператора — CLI (меню, start/stop/…), как прежний awg-ctrl.
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: awg-control <ctrl|ui|agent|start|stop|restart|status|…>")
os.Exit(1)
cli.Main(nil)
return
}
switch os.Args[1] {
case "version":
fmt.Println(version)
case "ctrl":
s, err := ctrl.New()
if err != nil {
@@ -24,8 +37,38 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "ui":
a, err := ui.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := a.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "agent":
c, err := agent.ConfigFromEnv()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
a, err := agent.New(c)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
a.Run(ctx)
case "relay":
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := relay.Main(ctx); err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "awg-control: неизвестная команда %q\n", os.Args[1])
os.Exit(1)
cli.Main(os.Args[1:])
}
}
+80 -90
View File
@@ -21,7 +21,7 @@ fail() { trap - ERR; echo -e "\n ${RED}✗${NC} $*" >&2; exit 1; }
info() { echo -e " ${DIM}→ $*${NC}"; }
skip() { echo -e " ${DIM}– $*${NC}"; }
rule() { echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"; }
# Заголовок шага: step "1/7 AmneziaWG" → «▍ AmneziaWG 1 из 7»; без номера — просто заголовок.
# Заголовок шага: step "1/6 AmneziaWG" → «▍ AmneziaWG 1 из 6»; без номера — просто заголовок.
step() {
if [[ "$1" =~ ^([0-9]+)/([0-9]+)\ +(.*)$ ]]; then
echo -e "\n${ACC}▍${NC}${BLD} ${BASH_REMATCH[3]}${NC} ${DIM}${BASH_REMATCH[1]} из ${BASH_REMATCH[2]}${NC}"
@@ -84,7 +84,7 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
# Лог установки — с первой строки: всё, что видно на экране (шапка, проверки, вопросы с
# ответами, вывод apt/DKMS/npm), плюс то, что установщик раньше глушил. В файле — без цветов
# ответами, вывод apt/DKMS), плюс то, что установщик раньше глушил. В файле — без цветов
# и без перерисовок строк (ESC-последовательности и всё до последнего \r вырезаются).
# Права 600: в итоге печатается пароль панели. LOGFILE — глобал, на него ссылается ERR-трап.
LOGFILE="/var/log/awg-install-$(date +%Y%m%d-%H%M%S).log"
@@ -100,23 +100,29 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места.
VERSION="0.3.4"
VERSION="0.4.5"
CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
# ничего не зашито. У Gitea и GitHub путь релиза одинаковый
# (<repo>/releases/download/v<VERSION>/<file>), поэтому одна схема покрывает оба.
# REPO_BASE — основной источник (своё зеркало);
# REPO_FALLBACK — запасной, если в основном релиза ещё нет;
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
# переопределяется окружением. Путь релиза — <repo>/releases/download/v<VERSION>/<file>
# (такой же у Gitea, так что подойдёт и своё зеркало).
# REPO_BASE — основной источник;
# REPO_FALLBACK — запасной, если в основном релиза нет (по умолчанию не задан);
# ARCHIVE_URL — полный URL архива, перекрывает оба варианта;
# BRAND — имя продукта в баннерах, systemd-юните и панели; по
# умолчанию берётся из .env в архиве, здесь только запасное
# значение для баннера самого установщика.
BRAND_OVERRIDE="${BRAND:-}"
BRAND="${BRAND:-Forgetting}"
REPO_BASE="${REPO_BASE:-https://git.ma7neko.ru/maeneko/forgetting}"
REPO_FALLBACK="${REPO_FALLBACK:-https://github.com/maeneko/forgetting}"
ARCHIVE_NAME="awgcontrol-${VERSION}.tar.gz"
REPO_BASE="${REPO_BASE:-https://github.com/maeneko/forgetting}"
REPO_FALLBACK="${REPO_FALLBACK:-$REPO_BASE}"
# Архив — под архитектуру: в нём готовый бинарник awg-control (Go, статический).
case "$(uname -m)" in
x86_64|amd64) GOARCH="amd64" ;;
aarch64|arm64) GOARCH="arm64" ;;
*) GOARCH="" ;;
esac
ARCHIVE_NAME="awgcontrol-${VERSION}-linux-${GOARCH:-unknown}.tar.gz"
ARCHIVE_URL="${ARCHIVE_URL:-}"
url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; }
PROJECT="/opt/awg-control"
@@ -147,10 +153,9 @@ AWG_PORT="47619"
SUBNET="10.9"
MTU="1376"
# Запуск CLI: tsx напрямую из node_modules (не npx) — чтобы PID был реальным
# (та же причина, что у обёртки /usr/local/bin/awg-ctrl). Нужно обоим режимам.
TSX="$PROJECT/cli/node_modules/.bin/tsx"
CLI="$PROJECT/cli/src/index.ts"
# Единый бинарник: сервисы (ctrl/ui/agent) и CLI (start/stop/status/…). Команда awg-ctrl —
# ссылка на него.
BIN="$PROJECT/awg-control"
echo
echo -e " ${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}"
@@ -270,8 +275,14 @@ echo -e " ${DIM}${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)} ·
# Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист
# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим
# с кодом 0 (чистый выход, без вида «упало с ошибкой»).
OS_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1
OS_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY=""
OS_OK=1; ARCH_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1
OS_WHY=""; ARCH_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY=""
# Архитектура: релиз собирается под amd64 и arm64.
if [[ -z "$GOARCH" ]]; then
ARCH_OK=0
ARCH_WHY="архитектура '$(uname -m)' не поддерживается — сборки есть для x86_64 и aarch64"
fi
# 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian.
if [[ -z "$OS_FAMILY" ]]; then
@@ -304,7 +315,7 @@ if [[ "$ROLE" != "core" ]]; then
fi
fi
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
# 3) Доступ в интернет: нужен для архива и пакетов AWG. Без -f: любой
# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS.
# Проверяем хосты, с которых реально качаем: хватает любого из двух —
# download_extract() умеет откатываться на запасной.
@@ -321,16 +332,18 @@ done
# Чеклист: ✓ — пройдено, ✗ — нет.
mark() { if [[ "$1" == 1 ]]; then echo -e " ${GRN}✓${NC} $2"; else echo -e " ${RED}✗${NC} $2"; fi; }
mark "$OS_OK" "Дистрибутив"
mark "$ARCH_OK" "Архитектура"
if [[ "$ROLE" != "core" ]]; then
mark "$VIRT_OK" "Виртуализация"
mark "$HDR_OK" "Заголовки ядра"
fi
mark "$NET_OK" "Доступ в интернет"
if [[ "$OS_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then
if [[ "$OS_OK" == 0 || "$ARCH_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then
echo
warn "Установка невозможна — не пройдены проверки:"
[[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY"
[[ "$ARCH_OK" == 0 ]] && echo -e " ${RED}•${NC} $ARCH_WHY"
[[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY"
[[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY"
[[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY"
@@ -388,14 +401,19 @@ NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
# Сначала корректно через CLI (по PID-файлам), затем добиваем всё, что ещё
# держит файлы проекта: осиротевшие процессы, ручной запуск или stale PID,
# которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе node
# продолжит работать с уже удалёнными файлами и порт/интерфейс останутся занятыми.
# которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе сервисы
# продолжат работать с уже удалёнными файлами и порт/интерфейс останутся занятыми.
kill_related() {
local _tsx="$PROJECT/cli/node_modules/.bin/tsx"
local _cli="$PROJECT/cli/src/index.ts"
[[ -x "$_tsx" && -f "$_cli" ]] && "$_tsx" "$_cli" stop all 2>/dev/null || true
if [[ -x "$BIN" ]]; then
"$BIN" stop all 2>/dev/null || true
else
# Установка до Go-версии: её CLI на tsx.
local _tsx="$PROJECT/cli/node_modules/.bin/tsx"
local _cli="$PROJECT/cli/src/index.ts"
[[ -x "$_tsx" && -f "$_cli" ]] && "$_tsx" "$_cli" stop all 2>/dev/null || true
fi
# cmdline всех сервисов (tsx awg-ctrl/awg-ui/cli) содержит путь проекта.
# cmdline всех сервисов (awg-control <роль>, у старых — tsx …) содержит путь проекта.
if command -v pkill &>/dev/null; then
pkill -TERM -f "$PROJECT/" 2>/dev/null || true
sleep 1
@@ -420,7 +438,7 @@ download_extract() {
# AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах (или
# встать без сети). Архив должен быть той же формы, что делает CI:
# awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/ + .env, без node_modules.
# awg-control (бинарник под эту архитектуру) + awg-ui/dist/ + .env.
if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then
[[ -f "$AWG_LOCAL_TARBALL" ]] \
|| fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL"
@@ -451,42 +469,40 @@ $(printf ' %s\n' "${urls[@]}")
fi
mkdir -p "$PROJECT"
remove_legacy
run tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \
|| fail "Не удалось распаковать архив"
[[ "$keep_tarball" == 1 ]] || rm -f "$tmp"
ok "Распакован → $PROJECT"
local f
for f in \
"$PROJECT/awg-ctrl/index.ts" \
"$PROJECT/awg-ctrl/package.json" \
"$PROJECT/awg-ui/public/index.html" \
"$PROJECT/awg-ui/server.ts" \
"$PROJECT/awg-ui/package.json" \
"$PROJECT/awg-agent/index.ts" \
"$PROJECT/awg-agent/package.json" \
"$PROJECT/cli/src/index.ts" \
"$PROJECT/cli/package.json"
do
for f in "$BIN" "$PROJECT/awg-ui/dist/index.html" "$PROJECT/.env"; do
[[ -f "$f" ]] || fail "Файл не найден после распаковки: $f"
done
# Рабочие каталоги сервисов: в них живут panel.id, логи awg-ctrl, cli.env.
mkdir -p "$PROJECT/awg-ctrl" "$PROJECT/awg-ui" "$PROJECT/awg-agent" "$PROJECT/cli" "$PROJECT/logs"
chmod 755 "$BIN"
"$BIN" version >/dev/null 2>&1 || fail "Бинарник $BIN не запускается (архив не под эту архитектуру?)"
ok "Архив проверен"
}
# npm install во всех сервисах.
npm_install_all() {
local SVC
for SVC in awg-ctrl awg-ui awg-agent cli; do
if [[ -f "$PROJECT/$SVC/package.json" ]]; then
info "npm install · $SVC"
(cd "$PROJECT/$SVC" && run npm install) \
|| fail "npm install в $SVC завершился ошибкой"
else
warn "$PROJECT/$SVC/package.json не найден — пропуск"
fi
# remove_legacy — при обновлении убрать код прежней версии: TS-исходники, node_modules и старую
# сборку фронтенда. Данные остаются: cli/cli.env, awg-ui/panel.id, логи (базы и ключи — в /etc).
remove_legacy() {
local d
for d in awg-ctrl awg-ui awg-agent cli; do
[[ -d "$PROJECT/$d" ]] || continue
rm -rf "$PROJECT/$d/node_modules" "$PROJECT/$d/src" "$PROJECT/$d/public" "$PROJECT/$d/dist"
rm -f "$PROJECT/$d"/*.ts "$PROJECT/$d"/package*.json "$PROJECT/$d"/tsconfig*.json "$PROJECT/$d/index.html"
done
}
# Команда awg-ctrl — ссылка на бинарник: CLI находит корень установки по её цели.
install_command() {
ln -sfn "$BIN" /usr/local/bin/awg-ctrl
ok "awg-ctrl → $BIN"
}
# systemd-юнит: автозапуск awg-control на загрузке. Модель CLI — детач-процессы
# с PID-файлами (а не один долгоживущий процесс), поэтому Type=oneshot +
# RemainAfterExit: systemd держит юнит «active», процессами рулит CLI через
@@ -494,12 +510,8 @@ npm_install_all() {
# вручную не выполняется — поднимаем интерфейс в ExecStartPre (если ещё не поднят).
SERVICE_UNIT="/etc/systemd/system/awg-control.service"
setup_service() {
# node может стоять вне дефолтного PATH systemd (nvm и т.п.), а tsx
# запускается через shebang `#!/usr/bin/env node` — иначе ExecStart падает с
# кодом 127 «node not found». Прописываем реальный каталог node в PATH юнита;
# sbin тоже включаем (awg-quick зовёт iptables/ip/sysctl).
local node_dir pre="" what
node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")
# sbin в PATH юнита: awg-quick зовёт iptables/ip/sysctl.
local pre="" what
# Панели без VPN (core) интерфейс не нужен — ExecStartPre только там, где есть awg-ctrl.
[[ "$ROLE" != "core" ]] && pre="ExecStartPre=/bin/sh -c 'awg show ${IFACE} >/dev/null 2>&1 || awg-quick up ${IFACE}'"
case "$ROLE" in
@@ -516,7 +528,7 @@ Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=PATH=${node_dir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
${pre}
ExecStart=/usr/local/bin/awg-ctrl start all
ExecStop=/usr/local/bin/awg-ctrl stop all
@@ -539,10 +551,10 @@ start_and_status() {
|| fail "systemctl restart awg-control завершился ошибкой"
else
warn "systemd не найден — запускаю напрямую через CLI (без автозапуска)"
run "$TSX" "$CLI" start all
run "$BIN" start all
fi
sleep 2
"$TSX" "$CLI" status
"$BIN" status
}
if [[ "$INSTALL_MODE" == "update" ]]; then
@@ -552,7 +564,7 @@ if [[ "$INSTALL_MODE" == "update" ]]; then
kill_related
download_extract
npm_install_all
install_command
start_and_status
rule
@@ -690,8 +702,9 @@ parse_join() {
CORE_PIN=$(sed -nE 's/.*"pin":"([A-Za-z0-9_-]{43})".*/\1/p' <<< "$json")
NODE_ID=$(sed -nE 's/.*"n":([0-9]+)[,}].*/\1/p' <<< "$json")
JOIN_SECRET=$(sed -nE 's/.*"s":"([A-Za-z0-9_-]{22})".*/\1/p' <<< "$json")
[[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" ]] \
&& (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID > 0 ))
# NODE_ID 0 — сервер, с которого переехала панель (awg-ctrl migrate import).
[[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" && -n "$NODE_ID" ]] \
&& (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID >= 0 ))
}
# SHA-256 от SPKI сертификата панели, base64url без «=» — в том же виде, что pin в строке.
@@ -792,7 +805,7 @@ ask YN "Продолжить? [Y/n]: "
# Совместимость (виртуализация, заголовки ядра, интернет) проверена выше в
# секции «Проверка совместимости»; $KERNEL задан там же.
step "1/7 AmneziaWG"
step "1/6 AmneziaWG"
if [[ "$ROLE" == "core" ]]; then
skip "пропущено — у панели без VPN AmneziaWG не нужен"
@@ -1022,24 +1035,11 @@ else
fi
fi
step "2/7 Node.js"
if command -v node &>/dev/null; then
ok "Node.js уже установлен: $(node --version)"
else
info "установка Node.js 20.x (nodesource)"
run bash -c 'set -o pipefail; curl -fsSL https://deb.nodesource.com/setup_20.x | bash -' \
|| fail "Не удалось подключить репозиторий Node.js (deb.nodesource.com)"
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
run apt-get install -y nodejs || fail "Не удалось установить Node.js"
ok "Node.js установлен: $(node --version)"
fi
step "3/7 Файлы проекта"
step "2/6 Файлы проекта"
download_extract
step "4/7 Ключи и конфиг AWG"
step "3/6 Ключи и конфиг AWG"
mkdir -p "$AWG_DIR" "$AMNEZIA_DIR"
@@ -1196,7 +1196,7 @@ CONF
fi
fi
step "5/7 Запуск AWG"
step "4/6 Запуск AWG"
if [[ "$ROLE" == "core" ]]; then
skip "пропущено — у панели без VPN интерфейса нет"
@@ -1307,21 +1307,11 @@ else
info "UFW не активен, правила фаервола не трогаем"
fi
step "6/7 npm install"
step "5/6 Команда awg-ctrl"
npm_install_all
install_command
# Глобальный бинарник — используем tsx напрямую из node_modules (не npx)
# чтобы child.pid в cli был реальным PID процесса
cat > /usr/local/bin/awg-ctrl << 'WRAPPER'
#!/usr/bin/env bash
exec /opt/awg-control/cli/node_modules/.bin/tsx \
/opt/awg-control/cli/src/index.ts "$@"
WRAPPER
chmod +x /usr/local/bin/awg-ctrl
ok "awg-ctrl → /usr/local/bin/awg-ctrl"
step "7/7 Запуск сервисов"
step "6/6 Запуск сервисов"
# cli.env по роли. Роль CLI узнаёт отсюда же: CORE_HOST → нода (awg-ctrl + awg-agent),
# LOCAL_NODE=off → панель без VPN (только awg-ui), иначе — awg-ctrl + awg-ui.
+530
View File
@@ -0,0 +1,530 @@
// Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее
// WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет
// присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет,
// awg-ctrl остаётся закрытым от сети. Протокол и формат join-строки — docs/node-protocol.md.
package agent
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64 = base64.RawURLEncoding
const (
handshakeTimeout = 10 * time.Second
deadAfter = 60 * time.Second // core пингует чаще; тишина дольше — соединение мёртвое
maxPayload = 4 * 1024 * 1024
ctrlTimeout = 110 * time.Second
maxBackoff = 60 * time.Second
closeAuthFailed = 4003
)
type Config struct {
CoreHost string
CorePort int
CoreEndpoint string // «host:port», который core сообщил сам (после переезда панели); пробуется первым
CorePin string // SHA-256 SPKI сертификата core, base64url
NodeID int
Ctrl string // http://127.0.0.1:PORT
NodeKeyFile string
InternalKey string
CLIEnvFile string
JoinSecret string // одноразовый, живёт до первого успешного подключения
Version string
Logf, Errorf func(format string, a ...any)
}
// envFromFile — значение KEY=… из .env (как в Node-версии: первая строка, обрезанная).
func envFromFile(file, key string) string {
raw, err := os.ReadFile(file)
if err != nil {
return ""
}
m := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(.*)$`).FindSubmatch(raw)
if m == nil {
return ""
}
return strings.TrimSpace(string(m[1]))
}
// ConfigFromEnv собирает конфиг так же, как Node-агент. Каталог агента — рабочий каталог
// процесса (CLI запускает сервис с cwd = <root>/awg-agent): там node.key по умолчанию, а
// .env проекта — уровнем выше.
func ConfigFromEnv() (Config, error) {
cwd, _ := os.Getwd()
atoi := func(k string) int { n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(k))); return n }
// Нода №0 — сервер, с которого переехала панель: 0 здесь — значение, а не «не задано».
nodeID, idErr := strconv.Atoi(strings.TrimSpace(os.Getenv("NODE_ID")))
or := func(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
c := Config{
CoreHost: os.Getenv("CORE_HOST"),
CorePort: atoi("CORE_PORT"),
CorePin: os.Getenv("CORE_PIN"),
CoreEndpoint: os.Getenv("CORE_ENDPOINT"),
NodeID: nodeID,
Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"),
NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")),
InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"),
CLIEnvFile: os.Getenv("CLI_ENV_FILE"),
JoinSecret: os.Getenv("JOIN_SECRET"),
Version: os.Getenv("VERSION"),
}
if c.Version == "" {
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION")
}
if c.CoreHost == "" || c.CorePort == 0 || c.CorePin == "" || idErr != nil || c.NodeID < 0 {
return c, errors.New("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`")
}
return c, nil
}
type Agent struct {
c Config
nodeKey ed25519.PrivateKey
nodePubRaw string
internalKey ed25519.PrivateKey // nil — запросы к awg-ctrl уйдут без токена и получат 401
mu sync.Mutex
joinSecret string
addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT)
cur int
http *http.Client
}
// loadNodeKey — личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
func loadNodeKey(file string, logf func(string, ...any)) (ed25519.PrivateKey, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
if err := os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
return nil, err
}
logf("agent: создан ключ ноды %s", file)
}
return iauth.LoadPrivate(file)
}
func New(c Config) (*Agent, error) {
if c.Logf == nil {
c.Logf = func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
}
if c.Errorf == nil {
c.Errorf = func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
}
key, err := loadNodeKey(c.NodeKeyFile, c.Logf)
if err != nil {
return nil, fmt.Errorf("agent: ключ ноды %s: %w", c.NodeKeyFile, err)
}
a := &Agent{
c: c, nodeKey: key, joinSecret: c.JoinSecret,
nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)),
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout},
}
a.setAddrs(c.CoreEndpoint)
if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil {
a.internalKey = ik
} else {
c.Errorf("agent: приватный ключ внутренней авторизации не найден: %s", c.InternalKey)
}
return a, nil
}
// authMsg дублируется в awg-ui/nodes.ts.
func authMsg(nonce string, nodeID int) []byte {
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID))
}
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
var rePath = regexp.MustCompile(`^/[\x21-\x7e]*$`)
// pathAllowed — агент не открытый прокси: только API самого awg-ctrl. Строже Node-версии
// там, где та полагалась на нормализацию WHATWG URL: обратный слэш и «//» в начале
// (смена хоста) отвергаются сразу, «.»/«..» — и в %2e-записи.
func pathAllowed(p any) (string, bool) {
s, ok := p.(string)
if !ok || len(s) > 2048 || !rePath.MatchString(s) || strings.HasPrefix(s, "//") || strings.Contains(s, `\`) {
return "", false
}
pathname := s
if i := strings.IndexAny(pathname, "?#"); i >= 0 {
pathname = pathname[:i]
}
for _, seg := range strings.Split(pathname, "/") {
switch strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(seg, "%2e", "."), "%2E", ".")) {
case ".", "..":
return "", false
}
}
if pathname == "/health" || strings.HasPrefix(pathname, "/api/") || strings.HasPrefix(pathname, "/awg/") {
return s, true
}
return "", false
}
type reply struct {
status int
data any
}
func errReply(status int, msg string) reply {
return reply{status, jsjson.Object{{Key: "error", Val: msg}}}
}
// execute выполняет запрос core на локальном awg-ctrl. Ответ возвращается как его разобрал
// бы axios: JSON — значением, иначе строкой (пустое тело — "").
func (a *Agent) execute(m jsjson.Object) reply {
method, _ := m.Get2("method").(string)
method = strings.ToUpper(method)
p, ok := pathAllowed(m.Get2("path"))
if !methods[method] || !ok {
return errReply(400, "Запрос не разрешён агентом")
}
var body io.Reader
if v, has := m.Get("body"); has && v != nil {
raw, err := jsjson.Marshal(v)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
body = bytes.NewReader(raw)
}
u, err := url.Parse(a.c.Ctrl + p)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
u.Fragment = ""
req, err := http.NewRequest(method, u.String(), body)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
req.Header.Set("Content-Type", "application/json")
if a.internalKey != nil {
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now()))
}
resp, err := a.http.Do(req)
if err != nil {
return errReply(502, "awg-ctrl недоступен")
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errReply(502, "awg-ctrl недоступен")
}
if len(raw) == 0 {
return reply{resp.StatusCode, ""}
}
if v, err := jsjson.Decode(raw); err == nil {
return reply{resp.StatusCode, v}
}
return reply{resp.StatusCode, string(raw)}
}
// ── Соединение с core ──────────────────────────────────────────────────────
func spkiPin(rawCert []byte) (string, error) {
cert, err := x509.ParseCertificate(rawCert)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64.EncodeToString(sum[:]), nil
}
var errPin = errors.New("сертификат core не совпал с pin — соединение отклонено")
// tlsConfig — сертификат core самоподписанный: цепочку не проверяем, доверие даёт pin.
// Проверка внутри TLS-рукопожатия: секрет и подпись уходят только тому, чей сертификат
// совпал с pin из join-строки.
func (a *Agent) tlsConfig() *tls.Config {
return &tls.Config{
InsecureSkipVerify: true,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errPin
}
got, err := spkiPin(rawCerts[0])
if err != nil || got != a.c.CorePin {
return errPin
}
return nil
},
}
}
// forgetJoinSecret — после первого успешного входа секрет больше не нужен, убираем с диска.
func (a *Agent) forgetJoinSecret() {
a.mu.Lock()
a.joinSecret = ""
a.mu.Unlock()
os.Unsetenv("JOIN_SECRET")
if a.c.CLIEnvFile == "" {
return
}
raw, err := os.ReadFile(a.c.CLIEnvFile)
if err != nil {
return // не критично: секрет одноразовый и с истекающим сроком
}
var kept []string
for _, l := range strings.Split(string(raw), "\n") {
if !strings.HasPrefix(l, "JOIN_SECRET=") {
kept = append(kept, l)
}
}
os.WriteFile(a.c.CLIEnvFile, []byte(strings.Join(kept, "\n")), 0o600)
}
// setAddrs — сначала адрес, который сообщил core, потом адрес из join: как endpoints у клиента
// sen://. Чужой сервер по нему не подменить — доверие даёт pin, а не адрес.
func (a *Agent) setAddrs(learned string) {
a.mu.Lock()
defer a.mu.Unlock()
a.addrs = nil
for _, s := range []string{learned, hostPort(a.c.CoreHost, a.c.CorePort)} {
if s != "" && (len(a.addrs) == 0 || a.addrs[0] != s) {
a.addrs = append(a.addrs, s)
}
}
a.cur = 0
}
func (a *Agent) addr() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.addrs[a.cur%len(a.addrs)]
}
// next — не дозвонились (или core не принял): следующий адрес.
func (a *Agent) next() {
a.mu.Lock()
a.cur = (a.cur + 1) % len(a.addrs)
a.mu.Unlock()
}
// learnCore — core сообщил свой адрес (панель переехала, старый сервер скоро выключат):
// запоминаем в cli.env, чтобы после перезапуска звонить сразу туда. false — не запомнили.
func (a *Agent) learnCore(addr string) bool {
host, port, err := net.SplitHostPort(addr)
if p, perr := strconv.Atoi(port); err != nil || perr != nil || p <= 0 || p > 65535 || !reCoreHost.MatchString(host) {
return false
}
addr = hostPort(host, mustAtoi(port))
if addr == a.c.CoreEndpoint {
return true
}
if a.c.CLIEnvFile == "" {
return false
}
if err := envfile.Set(a.c.CLIEnvFile, [][2]string{{"CORE_ENDPOINT", addr}}); err != nil {
a.c.Errorf("agent: адрес core не сохранён: %v", err)
return false
}
a.c.CoreEndpoint = addr
a.mu.Lock()
cur := a.addrs[a.cur%len(a.addrs)]
a.mu.Unlock()
a.setAddrs(addr)
a.mu.Lock()
for i, s := range a.addrs { // текущее соединение не рвём: адрес пригодится при следующем
if s == cur {
a.cur = i
}
}
a.mu.Unlock()
a.c.Logf("agent: core теперь доступен и по %s", addr)
return true
}
var reCoreHost = regexp.MustCompile(`^[A-Za-z0-9.:-]{1,253}$`)
func mustAtoi(s string) int { n, _ := strconv.Atoi(s); return n }
func (a *Agent) secret() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.joinSecret
}
// connectOnce держит одно соединение до его закрытия. authFailed — core закрыл с 4003.
func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var dead *time.Timer
var deadMu sync.Mutex
armDead := func() {
deadMu.Lock()
defer deadMu.Unlock()
if dead != nil {
dead.Stop()
}
dead = time.AfterFunc(deadAfter, cancel)
}
defer func() {
deadMu.Lock()
if dead != nil {
dead.Stop()
}
deadMu.Unlock()
}()
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{
HTTPClient: &http.Client{Transport: tr},
CompressionMode: websocket.CompressionDisabled,
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
})
dcancel()
if err != nil {
if errors.Is(err, errPin) {
a.c.Errorf("agent: %v", errPin)
} else {
a.c.Errorf("agent: %v", err)
}
return false
}
defer ws.CloseNow()
ws.SetReadLimit(maxPayload)
send := func(v any) {
if b, err := jsjson.Marshal(v); err == nil {
ws.Write(ctx, websocket.MessageText, b)
}
}
ready := false
for {
_, raw, err := ws.Read(ctx)
if err != nil {
code := websocket.CloseStatus(err)
if !ready && code == -1 && ctx.Err() == nil {
a.c.Errorf("agent: %v", err)
}
if code == closeAuthFailed {
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
}
return code == closeAuthFailed
}
v, err := jsjson.Decode(raw)
if err != nil {
return false
}
m, _ := v.(jsjson.Object)
switch t, _ := m.Get2("t").(string); {
case t == "hello":
nonce, ok := m.Get2("nonce").(string)
if !ok {
continue
}
auth := jsjson.Object{
{Key: "t", Val: "auth"}, {Key: "node_id", Val: a.c.NodeID},
{Key: "sig", Val: b64.EncodeToString(ed25519.Sign(a.nodeKey, authMsg(nonce, a.c.NodeID)))},
}
if s := a.secret(); s != "" {
auth = append(auth, jsjson.KV{Key: "join", Val: jsjson.Object{{Key: "secret", Val: s}, {Key: "pub", Val: a.nodePubRaw}}})
}
if a.c.Version != "" {
auth = append(auth, jsjson.KV{Key: "version", Val: a.c.Version})
}
send(auth)
case t == "ready":
ready = true
armDead()
if onReady != nil {
onReady()
}
if a.secret() != "" {
a.forgetJoinSecret()
a.c.Logf("agent: нода привязана к core")
}
a.c.Logf("agent: подключено к core (%s)", a.addr())
// Подтверждение — только когда адрес уже на диске: по нему панель решает, что
// старый сервер можно выключать.
if core, ok := m.Get2("core").(string); ok && a.learnCore(core) {
send(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: core}})
}
case t == "req" && ready:
id, ok := m.Get2("id").(float64)
if !ok || id != float64(int64(id)) {
continue
}
// Запросы независимы, как async-обработчики в Node: медленный /awg/… не держит очередь.
go func() {
r := a.execute(m)
send(jsjson.Object{{Key: "t", Val: "res"}, {Key: "id", Val: id}, {Key: "status", Val: r.status}, {Key: "data", Val: r.data}})
}()
}
}
}
func hostPort(host string, port int) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
host = "[" + host + "]"
}
return host + ":" + strconv.Itoa(port)
}
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
func (a *Agent) Run(ctx context.Context) error {
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr())
backoff := time.Second
for {
ready := false
authFailed := a.connectOnce(ctx, func() { backoff, ready = time.Second, true })
if !ready {
a.next()
}
wait := backoff
if authFailed {
wait = maxBackoff
}
backoff = min(backoff*2, maxBackoff)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
}
+353
View File
@@ -0,0 +1,353 @@
package agent
import (
"context"
"crypto/ed25519"
"crypto/rand"
"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/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
func TestPathAllowed(t *testing.T) {
for p, want := range map[any]bool{
"/health": true,
"/api/users": true,
"/api/users/a?x=1": true,
"/awg/upgrade": true,
"/healthz": false,
"/api": false,
"/etc/passwd": false,
"//evil.com/api/x": false,
`/\evil.com/api/x`: false,
"/api/../etc": false,
"/api/%2e%2E/etc": false,
"/api/./x": false,
"/api/a b": false,
"api/users": false,
42: false,
"/api/" + strings.Repeat("a", 2048): false,
} {
if _, got := pathAllowed(p); got != want {
t.Errorf("pathAllowed(%v) = %v, want %v", p, got, want)
}
}
}
func writeKey(t *testing.T, file string, priv ed25519.PrivateKey) {
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatal(err)
}
os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
}
type core struct {
srv *httptest.Server
frames chan jsjson.Object // всё, что прислал агент
script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)
}
func newCore(t *testing.T, script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)) *core {
c := &core{frames: make(chan jsjson.Object, 16), script: script}
c.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/node/v1" {
http.NotFound(w, r)
return
}
ws, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer ws.CloseNow()
ctx := r.Context()
read := func() jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
return nil
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
c.frames <- o
return o
}
c.script(ctx, ws, read)
}))
t.Cleanup(c.srv.Close)
return c
}
func (c *core) pin(t *testing.T) string {
p, err := spkiPin(c.srv.Certificate().Raw)
if err != nil {
t.Fatal(err)
}
return p
}
func (c *core) hostPort() (string, int) {
u, _ := url.Parse(c.srv.URL)
port, _ := strconv.Atoi(u.Port())
return u.Hostname(), port
}
func newAgent(t *testing.T, c *core, pin, ctrlURL string) (*Agent, string) {
dir := t.TempDir()
host, port := c.hostPort()
_, ik, _ := ed25519.GenerateKey(rand.Reader)
writeKey(t, filepath.Join(dir, "internal.key"), ik)
envFile := filepath.Join(dir, "cli.env")
os.WriteFile(envFile, []byte("CORE_HOST=x\nJOIN_SECRET=s3cret\nNODE_ID=7\n"), 0o600)
a, err := New(Config{
CoreHost: host, CorePort: port, CorePin: pin, NodeID: 7, Ctrl: ctrlURL,
NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"),
CLIEnvFile: envFile, JoinSecret: "s3cret", Version: "Beta 0.3.4",
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
return a, envFile
}
func send(ctx context.Context, ws *websocket.Conn, v any) {
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(v))
}
func TestHandshakeAndRequests(t *testing.T) {
var internalPub ed25519.PublicKey
ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if !iauth.Verify(internalPub, tok, time.Now()) {
w.WriteHeader(401)
io.WriteString(w, `{"error":"Неверная авторизация"}`)
return
}
body, _ := io.ReadAll(r.Body)
switch r.URL.Path {
case "/api/users":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
io.WriteString(w, `{"got":`+string(body)+`, "q":"`+r.URL.RawQuery+`"}`)
case "/health":
io.WriteString(w, "ok")
default:
w.WriteHeader(204)
}
}))
defer ctrl.Close()
results := make(chan jsjson.Object, 8)
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n0nce"}})
auth := read()
if auth == nil {
return
}
join, _ := auth.Get2("join").(jsjson.Object)
pubRaw, _ := b64.DecodeString(join.Get2("pub").(string))
sig, _ := b64.DecodeString(auth.Get2("sig").(string))
if !ed25519.Verify(pubRaw, authMsg("n0nce", 7), sig) {
ws.Close(4003, "unauthorized")
return
}
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 1}, {Key: "method", Val: "post"},
{Key: "path", Val: "/api/users?x=1"}, {Key: "body", Val: jsjson.Object{{Key: "name", Val: "alice"}}}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/etc/passwd"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/health"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 4}, {Key: "method", Val: "DELETE"}, {Key: "path", Val: "/api/x"}})
for range 4 {
if r := read(); r != nil {
results <- r
}
}
ws.Close(websocket.StatusNormalClosure, "")
})
a, envFile := newAgent(t, c, c.pin(t), ctrl.URL)
internalPub = a.internalKey.Public().(ed25519.PublicKey)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if a.connectOnce(ctx, nil) {
t.Fatal("authFailed on a good handshake")
}
auth := <-c.frames
want := `{"t":"auth","node_id":7,"sig":"` + auth.Get2("sig").(string) + `","join":{"secret":"s3cret","pub":"` + a.nodePubRaw + `"},"version":"Beta 0.3.4"}`
if got := string(jsjson.MustMarshal(auth)); got != want {
t.Errorf("auth frame:\n got %s\nwant %s", got, want)
}
byID := map[float64]string{}
for range 4 {
r := <-results
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
}
for id, want := range map[float64]string{
1: `{"t":"res","id":1,"status":201,"data":{"got":{"name":"alice"},"q":"x=1"}}`,
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
3: `{"t":"res","id":3,"status":200,"data":"ok"}`,
4: `{"t":"res","id":4,"status":204,"data":""}`,
} {
if byID[id] != want {
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
}
}
// Секрет одноразовый: после ready он исчезает из памяти и из cli.env.
if a.secret() != "" {
t.Error("join secret still in memory")
}
if raw, _ := os.ReadFile(envFile); strings.Contains(string(raw), "JOIN_SECRET") || !strings.Contains(string(raw), "NODE_ID=7") {
t.Errorf("cli.env after join: %q", raw)
}
}
func TestWrongPinSendsNothing(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
})
a, _ := newAgent(t, c, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "http://127.0.0.1:1")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if a.connectOnce(ctx, nil) {
t.Fatal("pin mismatch reported as auth failure")
}
select {
case f := <-c.frames:
t.Fatalf("agent sent %v to a core with a foreign certificate", f)
default:
}
}
func TestAuthRejected(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
ws.Close(4003, "unauthorized")
})
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if !a.connectOnce(ctx, nil) {
t.Fatal("4003 not reported as auth failure")
}
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "JOIN_SECRET=s3cret") {
t.Error("join secret dropped without a successful join")
}
}
func TestNodeKeyPersists(t *testing.T) {
file := filepath.Join(t.TempDir(), "node.key")
k1, err := loadNodeKey(file, t.Logf)
if err != nil {
t.Fatal(err)
}
k2, _ := loadNodeKey(file, t.Logf)
if !k1.Equal(k2) {
t.Fatal("node key regenerated")
}
if st, _ := os.Stat(file); st.Mode().Perm() != 0o600 {
t.Fatalf("node.key mode %v", st.Mode().Perm())
}
}
// Нода #0 — сервер, с которого переехала панель: 0 — значение, а не «не задан».
func TestConfigNodeZero(t *testing.T) {
t.Setenv("CORE_HOST", "203.0.113.7")
t.Setenv("CORE_PORT", "40500")
t.Setenv("CORE_PIN", "pin")
for id, ok := range map[string]bool{"0": true, "3": true, "": false, "-1": false, "x": false} {
t.Setenv("NODE_ID", id)
c, err := ConfigFromEnv()
if (err == nil) != ok {
t.Errorf("NODE_ID=%q: %v", id, err)
}
if ok && strconv.Itoa(c.NodeID) != id {
t.Errorf("NODE_ID=%q → %d", id, c.NodeID)
}
}
}
// Панель переехала: core сообщает свой адрес в ready, агент записывает его в cli.env,
// подтверждает (core_ok) и с тех пор звонит туда первым, а адрес из join — запасной.
func TestLearnsCoreAddress(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}, {Key: "core", Val: "198.51.100.9:40500"}})
read()
ws.Close(websocket.StatusNormalClosure, "")
})
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
host, port := c.hostPort()
join := hostPort(host, port)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
a.connectOnce(ctx, nil)
<-c.frames // auth
if ok := <-c.frames; string(jsjson.MustMarshal(ok)) != `{"t":"core_ok","core":"198.51.100.9:40500"}` {
t.Errorf("подтверждение: %s", jsjson.MustMarshal(ok))
}
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "CORE_ENDPOINT=198.51.100.9:40500") {
t.Errorf("cli.env: %q", raw)
}
if strings.Join(a.addrs, " ") != "198.51.100.9:40500 "+join || a.addr() != join {
t.Errorf("адреса %v, текущий %s", a.addrs, a.addr())
}
}
func TestBadCoreAddressIgnored(t *testing.T) {
a := &Agent{c: Config{CoreHost: "203.0.113.1", CorePort: 40500, CLIEnvFile: filepath.Join(t.TempDir(), "cli.env"), Logf: t.Logf, Errorf: t.Logf}}
a.setAddrs("")
for _, bad := range []string{"", "x", "host:0", "host:99999", "a b:1", "host:1/x", "[::1]:x"} {
if a.learnCore(bad) {
t.Errorf("принят адрес %q", bad)
}
}
if !a.learnCore("[2001:db8::1]:443") || a.addrs[0] != "[2001:db8::1]:443" {
t.Errorf("IPv6: %v", a.addrs)
}
}
// Записанный адрес не отвечает (старый сервер выключен или новый ещё не открыт) — следующая
// попытка идёт на адрес из join.
func TestFallsBackToJoinAddress(t *testing.T) {
connected := make(chan struct{}, 1)
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
connected <- struct{}{}
<-ctx.Done()
})
a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
a.c.CoreEndpoint = "127.0.0.1:1"
a.setAddrs(a.c.CoreEndpoint)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go a.Run(ctx)
select {
case <-connected:
case <-ctx.Done():
t.Fatal("агент не перешёл на адрес из join")
}
}
+650
View File
@@ -0,0 +1,650 @@
// Package cli — менеджер процессов Forgetting (порт cli/src): start/stop/restart/status,
// логин и пароль панели, join/unjoin ноды, пиры подписки, встроенный Prometheus и
// интерактивное меню. Сервисы — тот же бинарник с ролью: `awg-control ctrl|ui|agent`.
package cli
import (
"bufio"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson"
)
// ── Цвета ───────────────────────────────────────────────────────────────────
const (
cReset = "\x1b[0m"
cBold = "\x1b[1m"
cDim = "\x1b[2m"
cGreen = "\x1b[32m"
cGrey = "\x1b[90m"
cBlue = "\x1b[34m"
)
func bold(s string) string { return cBold + s + cReset }
func green(s string) string { return cGreen + s + cReset }
func grey(s string) string { return cGrey + s + cReset }
func dim(s string) string { return cDim + s + cReset }
func errorf(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
// ── Окружение ───────────────────────────────────────────────────────────────
// findRoot — корень установки: AWG_ROOT, иначе ближайший предок каталога бинарника, где есть
// cli/ или .env, иначе /opt/awg-control.
func findRoot() string {
if r := os.Getenv("AWG_ROOT"); r != "" {
return r
}
if exe, err := os.Executable(); err == nil {
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
for d := filepath.Dir(exe); d != "/" && d != "."; d = filepath.Dir(d) {
if exists(filepath.Join(d, "cli")) || exists(filepath.Join(d, ".env")) {
return d
}
}
}
return "/opt/awg-control"
}
func exists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
// loadEnvFile — уже заданное окружение не перетираем (??= в Node), поэтому порядок =
// приоритет: реальное окружение → cli.env → .env в корне проекта.
func loadEnvFile(file string) {
raw, err := os.ReadFile(file)
if err != nil {
return
}
for _, line := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
i := strings.Index(t, "=")
if i < 0 {
continue
}
k := strings.TrimSpace(t[:i])
if _, set := os.LookupEnv(k); !set {
os.Setenv(k, strings.TrimSpace(t[i+1:]))
}
}
}
func env(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
// envOr — `process.env.X || def`.
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
type service struct {
name string // ctrl | ui | agent — роль бинарника
label string
cwd string
legacy string // entry Node-версии: её процессы тоже узнаём (переход с TS)
env [][2]string
pidFile string
logFile string
}
type CLI struct {
root string
logs string
envFile string
origEnv []string // окружение до чтения cli.env и .env — для свежего перезапуска
exe string
brand, channel, version string
svc map[string]*service
all []string
isNode bool
in *bufio.Reader
}
func New() *CLI {
c := &CLI{root: findRoot(), origEnv: os.Environ(), in: bufio.NewReader(os.Stdin)}
c.exe, _ = os.Executable()
c.logs = filepath.Join(c.root, "logs")
os.MkdirAll(c.logs, 0o755)
c.envFile = filepath.Join(c.root, "cli", "cli.env")
loadEnvFile(c.envFile) // машинные настройки
loadEnvFile(filepath.Join(c.root, ".env")) // имя панели и версия
c.brand = envOr("BRAND", "Forgetting")
c.channel = envOr("CHANNEL", "Beta")
c.version = envOr("VERSION", "")
cliEnv := c.envFile
coreHost := os.Getenv("CORE_HOST")
audit := "off"
if coreHost == "" {
audit = env("AUDIT", "on")
}
c.svc = map[string]*service{
"awgctrl": {
name: "ctrl", label: "awg-ctrl", cwd: filepath.Join(c.root, "awg-ctrl"), legacy: "index.ts",
env: [][2]string{
{"PORT", env("AWGCTRL_PORT", "3005")},
{"SERVER_IP", env("SERVER_IP", "")},
{"SERVER_PORT", env("SERVER_PORT", "47619")},
{"SERVER_NAME", env("SERVER_NAME", "VPN")},
// Публичный ключ внутренней авторизации (awg-ctrl проверяет им токены awg-ui).
{"INTERNAL_AUTH_PUB_FILE", env("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")},
},
pidFile: "/tmp/awg-ctrl.pid", logFile: filepath.Join(c.logs, "awg-ctrl.log"),
},
"ui": {
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
env: [][2]string{
{"PORT", env("UI_PORT", "8080")},
{"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},
{"UI_USER", env("UI_USER", "admin")},
{"UI_PASS", env("UI_PASS", "")},
{"JWT_SECRET", env("JWT_SECRET", "")},
// ui.db в каталоге данных AWG (вне проекта) — переживает переустановку.
{"UI_DB_FILE", env("UI_DB_FILE", "/etc/amnezia/amneziawg/ui.db")},
// Подписка sen://: без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
{"SUB_PORT", env("SUB_PORT", "")},
{"SUB_TLS", env("SUB_TLS", "off")},
{"SUB_SIGN_KEY_FILE", env("SUB_SIGN_KEY_FILE", "/etc/amnezia/amneziawg/sub_sign.key")},
{"SUB_TLS_KEY_FILE", env("SUB_TLS_KEY_FILE", "/etc/amnezia/amneziawg/sub_tls.key")},
{"SUB_TLS_CERT_FILE", env("SUB_TLS_CERT_FILE", "/etc/amnezia/amneziawg/sub_tls.crt")},
{"SUB_HOST", env("SUB_HOST", "")},
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
{"NODE_PORT", env("NODE_PORT", "")},
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
{"RELAY_FROM", env("RELAY_FROM", "")},
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
{"CLI_ENV_FILE", cliEnv},
// Часовой аудит пиров на всех серверах — только у core.
{"AUDIT", audit},
{"METRICS_PEERS", env("METRICS_PEERS", "off")},
{"LOCAL_NODE", env("LOCAL_NODE", "on")},
{"NODE_TLS_KEY_FILE", env("NODE_TLS_KEY_FILE", "/etc/amnezia/amneziawg/node_tls.key")},
{"NODE_TLS_CERT_FILE", env("NODE_TLS_CERT_FILE", "/etc/amnezia/amneziawg/node_tls.crt")},
},
pidFile: "/tmp/awg-ui.pid", logFile: filepath.Join(c.logs, "awg-ui.log"),
},
// Агент ноды: сам звонит в core и исполняет запросы на локальном awg-ctrl.
"agent": {
name: "agent", label: "awg-agent", cwd: filepath.Join(c.root, "awg-agent"), legacy: "index.ts",
env: [][2]string{
{"CORE_HOST", env("CORE_HOST", "")},
{"CORE_PORT", env("CORE_PORT", "")},
{"CORE_ENDPOINT", env("CORE_ENDPOINT", "")},
{"CORE_PIN", env("CORE_PIN", "")},
{"NODE_ID", env("NODE_ID", "")},
{"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")},
{"JOIN_SECRET", env("JOIN_SECRET", "")},
{"CLI_ENV_FILE", cliEnv},
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
},
pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"),
},
// Ретранслятор на сервере, с которого переехала панель: прежние порты → новая панель.
"relay": {
name: "relay", label: "awg-relay", cwd: c.root,
env: [][2]string{{"RELAY", env("RELAY", "")}},
pidFile: "/tmp/awg-relay.pid", logFile: filepath.Join(c.logs, "awg-relay.log"),
},
}
// Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с
// LOCAL_NODE=off — панель без своего VPN; иначе — обычная установка. Нода со своей панелью
// (join без --no-panel) держит и awg-ui: обе панели ходят в один awg-ctrl.
c.isNode = coreHost != ""
noLocal := strings.ToLower(env("LOCAL_NODE", "on")) == "off"
nodePanel := c.isNode && os.Getenv("UI_PASS") != "" && strings.ToLower(env("NODE_PANEL", "on")) != "off"
switch {
case c.isNode && nodePanel:
c.all = []string{"awgctrl", "ui", "agent"}
case c.isNode:
c.all = []string{"awgctrl", "agent"}
case noLocal:
c.all = []string{"ui"}
default:
c.all = []string{"awgctrl", "ui"}
}
// Панель переехала (`migrate export`): здесь её больше не запускаем — вторая копия ui.db
// разошлась бы с перенесённой.
if c.panelMoved() {
c.all = without(c.all, "ui")
}
if os.Getenv("RELAY") != "" {
c.all = append(c.all, "relay")
}
return c
}
func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" }
func without(s []string, v string) []string {
var out []string
for _, x := range s {
if x != v {
out = append(out, x)
}
}
return out
}
func (c *CLI) envOf(name string) string {
for _, kv := range c.svc[name].env {
if kv[0] == "PORT" {
return kv[1]
}
}
return ""
}
func (c *CLI) svcEnv(name, key string) string {
for _, kv := range c.svc[name].env {
if kv[0] == key {
return kv[1]
}
}
return ""
}
// ── Процессы ────────────────────────────────────────────────────────────────
func (c *CLI) readPid(name string) int {
raw, err := os.ReadFile(c.svc[name].pidFile)
if err != nil {
return 0
}
n, err := strconv.Atoi(firstInt(strings.TrimSpace(string(raw))))
if err != nil {
return 0
}
return n
}
var reLeadInt = regexp.MustCompile(`^[+-]?\d+`)
// firstInt — parseInt: ведущее целое, остальное игнорируется.
func firstInt(s string) string { return reLeadInt.FindString(s) }
// isAlive — EPERM: процесс есть, просто не наш (считаем живым).
func isAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
// pidMatches — защита от переиспользования PID: сверяем cmdline с сервисом. Узнаём и Go-сервис
// (`awg-control <роль>`), и процесс Node-версии (entry-файл в cmdline). Без procfs проверить нечем.
func (c *CLI) pidMatches(name string, pid int) bool {
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
return true
}
args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
s := c.svc[name]
if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name {
return true
}
if s.legacy == "" {
return false
}
// tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent.
return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd))
}
func (c *CLI) isRunning(name string) bool {
pid := c.readPid(name)
return pid != 0 && isAlive(pid) && c.pidMatches(name, pid)
}
func (c *CLI) start(name string) {
s := c.svc[name]
if c.isRunning(name) {
fmt.Printf(" %s: already running pid=%d\n", s.label, c.readPid(name))
return
}
if name == "awgctrl" {
if f := c.svcEnv("awgctrl", "INTERNAL_AUTH_PUB_FILE"); !exists(f) {
errorf(" awgctrl: публичный ключ внутренней авторизации не найден: %s", f)
os.Exit(1)
}
}
logF, err := os.OpenFile(s.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
errorf(" %s: не удалось запустить — %v", s.label, err)
return
}
defer logF.Close() // дескриптор унаследован ребёнком — родителю не нужен
cmd := exec.Command(c.exe, s.name)
cmd.Dir = s.cwd
if !exists(s.cwd) {
cmd.Dir = c.root
}
cmd.Env = os.Environ()
for _, kv := range s.env {
cmd.Env = append(cmd.Env, kv[0]+"="+kv[1])
}
cmd.Stdout, cmd.Stderr = logF, logF
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // detached: переживает CLI
if err := cmd.Start(); err != nil {
errorf(" %s: не удалось запустить — %v", s.label, err)
os.Remove(s.pidFile)
return
}
go cmd.Wait() // пока CLI жив (меню), не оставляем зомби
os.WriteFile(s.pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644)
fmt.Printf(" %s %s: started pid=%d\n", green("▶"), s.label, cmd.Process.Pid)
}
func (c *CLI) stop(name string) {
s := c.svc[name]
pid := c.readPid(name)
if pid == 0 || !isAlive(pid) || !c.pidMatches(name, pid) {
fmt.Printf(" %s: not running\n", s.label)
os.Remove(s.pidFile)
return
}
syscall.Kill(pid, syscall.SIGTERM)
// Ждём мягкого завершения до 5 с, иначе добиваем SIGKILL.
deadline := time.Now().Add(5 * time.Second)
for isAlive(pid) && !isZombie(pid) && time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
}
if isAlive(pid) && !isZombie(pid) {
syscall.Kill(pid, syscall.SIGKILL)
time.Sleep(200 * time.Millisecond)
}
os.Remove(s.pidFile)
fmt.Printf(" %s %s: stopped pid=%d\n", grey("■"), s.label, pid)
}
// isZombie — процесс завершился, но его ещё не забрал родитель (наш же Wait в меню).
func isZombie(pid int) bool {
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return false
}
s := string(raw)
if i := strings.LastIndex(s, ")"); i >= 0 && i+2 < len(s) {
return s[i+2] == 'Z'
}
return false
}
// fetchHealth — GET /health и разбор JSON. Любая ошибка — nil.
func fetchHealth(url string) any {
cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get(url)
if err != nil {
return nil
}
defer resp.Body.Close()
var b strings.Builder
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
b.WriteString(string(buf[:n]))
if err != nil {
break
}
}
v, err := jsjson.Decode([]byte(b.String()))
if err != nil {
return nil
}
return v
}
func get(v any, keys ...string) any {
for _, k := range keys {
o, ok := v.(jsjson.Object)
if !ok {
return nil
}
v = o.Get2(k)
}
return v
}
func strOr(v any, def string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return def
}
func (c *CLI) serviceLine(name string, menu bool) string {
running := c.isRunning(name)
dot, info := grey("○"), grey("stopped")
if running {
dot = green("●")
if menu {
info = green("running") + dim(fmt.Sprintf(" pid %d", c.readPid(name)))
} else {
info = green("running") + fmt.Sprintf(" pid %d", c.readPid(name))
}
}
return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info)
}
func (c *CLI) status() {
fmt.Println()
for _, n := range c.all {
fmt.Println(c.serviceLine(n, false))
}
if c.isRunning("awgctrl") {
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
if gen, ok := get(h, "gen").(string); ok && gen != "" {
g := "2.0"
if gen == "3.1" {
g = "3.1"
}
fmt.Println(" " + dim(fmt.Sprintf("AmneziaWG %s · модуль %s · tools %s", g, strOr(get(h, "awg", "module"), "?"), strOr(get(h, "awg", "tools"), "?"))))
}
}
fmt.Println()
}
// probe — любой HTTP-ответ (даже 401/503) значит, что порт слушается.
func probe(url string) bool {
cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get(url)
if err != nil {
return false
}
resp.Body.Close()
return true
}
// waitForHealth — ждём, пока awg-ctrl начнёт отвечать; умер — сразу выходим.
func (c *CLI) waitForHealth() bool {
url := "http://127.0.0.1:" + c.envOf("awgctrl") + "/health"
for range 10 {
if !c.isRunning("awgctrl") {
return false
}
if probe(url) {
return true
}
time.Sleep(300 * time.Millisecond)
}
return false
}
func (c *CLI) startAll() {
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
if !contains(c.all, "awgctrl") {
for _, n := range c.all {
c.start(n)
}
return
}
// Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN.
if contains(c.all, "relay") {
c.start("relay")
}
c.start("awgctrl")
var rest, labels []string
for _, n := range c.all {
if n != "awgctrl" && n != "relay" {
rest = append(rest, n)
labels = append(labels, c.svc[n].label)
}
}
if !c.isRunning("awgctrl") || !c.waitForHealth() {
errorf(" %s awg-ctrl не отвечает — %s не запущен (см. %s)", grey("■"), strings.Join(labels, ", "), c.svc["awgctrl"].logFile)
return
}
for _, n := range rest {
c.start(n)
}
}
func (c *CLI) stopAll() {
for _, n := range c.all {
c.stop(n)
}
}
func (c *CLI) restartAll() {
c.stopAll()
c.startAll()
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ── cli.env ─────────────────────────────────────────────────────────────────
func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) }
func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
func (c *CLI) readEnvFile() string {
raw, _ := os.ReadFile(c.envFile)
return string(raw)
}
// writeEnvFile — новый файл с правами 0600 (в нём пароль панели); у существующего права не трогаем.
func (c *CLI) writeEnvFile(content string) error {
return os.WriteFile(c.envFile, []byte(content), 0o600)
}
// restartFresh — роль читается из окружения при старте процесса, поэтому после смены cli.env
// перезапуск — свежим процессом с исходным окружением (без значений из старого cli.env).
func (c *CLI) restartFresh() {
cmd := exec.Command(c.exe, "restart")
cmd.Env = c.origEnv
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Run()
}
// ── Ввод ────────────────────────────────────────────────────────────────────
func (c *CLI) ask(prompt string) string {
fmt.Print(prompt)
line, _ := c.in.ReadString('\n')
return strings.TrimRight(line, "\r\n")
}
var reYes = regexp.MustCompile(`(?i)^y`)
func (c *CLI) confirmYes(q string) bool {
return reYes.MatchString(strings.TrimSpace(c.ask(q + " [y/N] ")))
}
// ── Точка входа ─────────────────────────────────────────────────────────────
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>>"
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"}
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
func Main(args []string) {
c := New()
if len(args) == 0 {
c.interactiveMenu()
return
}
arg := func(i int) (string, bool) {
if i < len(args) {
return args[i], true
}
return "", false
}
sub, hasSub := arg(1)
a2, hasA2 := arg(2)
switch args[0] {
case "start":
c.startAll()
case "stop":
c.stopAll()
case "restart":
c.restartAll()
case "status":
c.status()
case "credentials":
switch sub {
case "user", "pass":
c.setCredentials(sub)
default:
c.setCredentials("")
}
case "join":
c.joinCore(sub, hasSub, a2, hasA2)
case "unjoin":
c.unjoinCore()
case "rehome":
c.rehome(sub, hasSub)
case "migrate":
c.migrateCmd(sub, a2, hasA2)
case "peers":
c.peersCmd(sub, hasSub, a2, hasA2)
case "monitor":
c.monitorCmd(sub)
default:
fmt.Println(usage)
os.Exit(1)
}
}
+44
View File
@@ -0,0 +1,44 @@
package cli
import "testing"
func TestUpdateEnvVar(t *testing.T) {
for _, c := range []struct{ in, key, val, want string }{
{"", "A", "1", "\nA=1\n"},
{"A=0\nB=2\n", "A", "1", "A=1\nB=2\n"},
{"B=2\n\n\n", "A", "1", "B=2\nA=1\n"},
{"XA=0\nA=0\n", "A", "$&1", "XA=0\nA=$&1\n"},
{"A=0\nA=9\n", "A", "1", "A=1\nA=9\n"}, // как String.replace без g — только первое
} {
if got := updateEnvVar(c.in, c.key, c.val); got != c.want {
t.Errorf("updateEnvVar(%q, %s, %s) = %q, want %q", c.in, c.key, c.val, got, c.want)
}
}
if got := removeEnvVar("A=1\nJOIN_SECRET=x\nB=2\n", "JOIN_SECRET"); got != "A=1\nB=2\n" {
t.Errorf("removeEnvVar: %q", got)
}
}
func TestAreaOf(t *testing.T) {
for owner, want := range map[string]string{
"abcdef123456/m1/d2": "abcdef123456",
"m1/d2": "legacy",
"alice": "",
"ABCDEF123456/m1/d2": "",
"abc/m1/d2": "",
"": "",
} {
if got := areaOf(owner); got != want {
t.Errorf("areaOf(%q) = %q, want %q", owner, got, want)
}
}
}
func TestPeersWord(t *testing.T) {
for n, want := range map[int]string{0: "пиров", 1: "пир", 2: "пира", 4: "пира", 5: "пиров", 11: "пиров", 12: "пиров",
21: "пир", 22: "пира", 101: "пир", 111: "пиров", 114: "пиров", 1004: "пира"} {
if got := peersWord(n); got != want {
t.Errorf("peersWord(%d) = %s, want %s", n, got, want)
}
}
}
+525
View File
@@ -0,0 +1,525 @@
package cli
import (
"bytes"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64url = base64.RawURLEncoding
// fromB64url — Buffer.from(s, "base64url"): нестрого, как в Node.
func fromB64url(s string) []byte {
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
if len(s)%4 == 1 {
s = s[:len(s)-1]
}
b, _ := b64url.DecodeString(s)
return b
}
// ── join / unjoin ───────────────────────────────────────────────────────────
var (
reJoinHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`)
reJoinPin = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
reJoinSec = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
)
func isPosInt(v any, max float64) (int64, bool) {
f, ok := v.(float64)
return int64(f), ok && f == float64(int64(f)) && f > 0 && f < max
}
// joinCore — `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как
// ноду. Рядом запускается агент; awg-ctrl и пользователи остаются как были. Своя панель
// продолжает работать, если она здесь есть, — с --no-panel она останавливается.
func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
if !hasLink || !strings.HasPrefix(link, "awgjoin://") || hasFlag && flag != "--no-panel" {
errorf(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)")
os.Exit(1)
}
v, err := jsjson.Decode(fromB64url(link[len("awgjoin://"):]))
j, isObj := v.(jsjson.Object)
if err != nil || !isObj {
errorf(" Ссылка повреждена")
os.Exit(1)
}
h, hok := j.Get2("h").(string)
pin, pinok := j.Get2("pin").(string)
sec, sok := j.Get2("s").(string)
port, pok := isPosInt(j.Get2("p"), 65536)
id, iok := isPosInt(j.Get2("n"), 9007199254740992)
// Нода #0 — этот же сервер после переноса панели (`migrate import` выдаёт строку с n = 0).
if n, ok := j.Get2("n").(float64); ok && n == 0 {
id, iok = 0, true
}
if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) ||
!iok || !sok || !reJoinSec.MatchString(sec) {
errorf(" Ссылка неверного формата или от другой версии панели")
os.Exit(1)
}
if c.isNode && !c.confirmYes(fmt.Sprintf(" Эта нода уже подключена к %s. Перепривязать?", os.Getenv("CORE_HOST"))) {
return
}
hasPanel := os.Getenv("UI_PASS") != ""
moved := c.panelMoved()
keepPanel := !hasFlag && hasPanel && !moved
content := c.readEnvFile()
np := "off"
if keepPanel {
np = "on"
}
for _, kv := range [][2]string{{"CORE_HOST", h}, {"CORE_PORT", fmt.Sprint(port)}, {"CORE_PIN", pin}, {"NODE_ID", fmt.Sprint(id)},
{"JOIN_SECRET", sec}, {"NODE_PANEL", np}} {
content = updateEnvVar(content, kv[0], kv[1])
}
content = removeEnvVar(content, "CORE_ENDPOINT") // адрес прежней панели к новой не относится
relay := ""
if moved {
relay = relayRoutes(h, port, j)
content = updateEnvVar(content, "RELAY", relay)
}
c.writeEnvFile(content)
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
// После переноса этими пирами управляет новая панель: снимать нечего.
dropPeers := !keepPanel && hasPanel && !moved && c.askDropPanelPeers()
if !keepPanel {
c.stop("ui")
}
if dropPeers {
c.dropArea(c.localPanelID())
}
tail := ""
if keepPanel {
tail = " · своя панель продолжает работать"
} else if hasPanel {
tail = " · своя панель остановлена"
}
fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail)
if relay != "" {
fmt.Println(" " + dim("Ретранслятор прежних портов на новую панель: "+relay))
}
c.restartFresh()
}
// relayRoutes — прежние порты этого сервера → новая панель. Подписка и хаб там на тех же
// портах (так их ставит `migrate import`), порт панели — из поля ui join-строки.
func relayRoutes(host string, hubPort int64, j jsjson.Object) string {
hp := func(p any) string { return net.JoinHostPort(host, fmt.Sprint(p)) }
var routes []string
add := func(local int, target string) {
if local > 0 {
routes = append(routes, fmt.Sprintf("%d=%s", local, target))
}
}
sub := envPort("SUB_PORT")
if p, ok := isPosInt(j.Get2("sub"), 65536); ok {
add(sub, hp(p))
} else {
add(sub, hp(sub))
}
add(envPort("NODE_PORT"), hp(hubPort))
if p, ok := isPosInt(j.Get2("ui"), 65536); ok {
add(envPort("UI_PORT"), hp(p))
}
return strings.Join(routes, ",")
}
func (c *CLI) unjoinCore() {
if !c.isNode {
fmt.Println(" Эта установка не подключена к core")
return
}
c.stop("agent")
content := c.readEnvFile()
for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL", "CORE_ENDPOINT"} {
content = removeEnvVar(content, k)
}
c.writeEnvFile(content)
fmt.Printf(" %s Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart\n", green("✓"))
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
if os.Getenv("UI_PASS") == "" {
fmt.Println(" " + dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials"))
}
}
// ── Пиры подписки (sen://) на локальном awg-ctrl ────────────────────────────
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели. Пиры
// без области («m1/d2») выданы до областей. Здесь — ручной рычаг для оператора: панель
// выключена или core пропал насовсем. По времени ничего не удаляется.
var (
reArea = regexp.MustCompile(`^[a-z0-9]{8,16}$`)
reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`)
)
func (c *CLI) localPanelID() string {
raw, err := os.ReadFile(filepath.Join(c.root, "awg-ui", "panel.id"))
if err != nil {
return ""
}
if id := strings.TrimSpace(string(raw)); reArea.MatchString(id) {
return id
}
return ""
}
type ctrlResp struct {
status int
data any
}
// ctrlCall — запрос к локальному awg-ctrl с тем же короткоживущим токеном, что у панели и агента.
func (c *CLI) ctrlCall(method, path string, body any) (*ctrlResp, error) {
key, err := iauth.LoadPrivate(c.svcEnv("ui", "INTERNAL_AUTH_KEY_FILE"))
if err != nil {
return nil, err
}
var rd io.Reader
if body != nil {
rd = bytes.NewReader(jsjson.MustMarshal(body))
}
req, err := http.NewRequest(method, "http://127.0.0.1:"+c.envOf("awgctrl")+path, rd)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+iauth.Mint(key, "awg-cli", time.Now()))
req.Header.Set("Content-Type", "application/json")
cl := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
data, err := jsjson.Decode(raw)
if err != nil {
data = nil
}
return &ctrlResp{resp.StatusCode, data}, nil
}
type ctrlPeer struct {
pub, ip, owner string
online bool
}
func ctrlError(r *ctrlResp) error {
if e, ok := get(r.data, "error").(string); ok {
return fmt.Errorf("%s", e)
}
return fmt.Errorf("awg-ctrl ответил %d", r.status)
}
func (c *CLI) listPeers() ([]ctrlPeer, error) {
r, err := c.ctrlCall("GET", "/api/peers", nil)
if err != nil {
return nil, err
}
if r.status != 200 {
return nil, ctrlError(r)
}
var out []ctrlPeer
list, _ := get(r.data, "peers").([]any)
for _, p := range list {
on, _ := get(p, "online").(bool)
out = append(out, ctrlPeer{pub: strOr(get(p, "pub_key"), ""), ip: strOr(get(p, "ip"), ""), owner: strOr(get(p, "owner"), ""), online: on})
}
return out, nil
}
// areaOf — область пира: id панели, «legacy» (до областей) или "" (чужой формат — не наш).
func areaOf(owner string) string {
head := strings.SplitN(owner, "/", 2)[0]
if strings.Contains(owner, "/") && reArea.MatchString(head) {
return head
}
if reLegacyOwner.MatchString(owner) {
return "legacy"
}
return ""
}
func peersWord(n int) string {
switch {
case n%10 == 1 && n%100 != 11:
return "пир"
case n%10 >= 2 && n%10 <= 4 && !(n%100 >= 12 && n%100 <= 14):
return "пира"
}
return "пиров"
}
func (c *CLI) askDropPanelPeers() bool {
own := c.localPanelID()
if own == "" {
return false
}
peers, err := c.listPeers()
if err != nil {
fmt.Fprintln(os.Stderr, " "+dim("Не удалось посмотреть пиров подписки: "+err.Error()))
return false
}
n := 0
for _, p := range peers {
if areaOf(p.owner) == own {
n++
}
}
if n == 0 {
return false
}
switch strings.ToLower(os.Getenv("NODE_PEERS")) {
case "keep":
return false
case "drop":
return true
}
fmt.Printf("\n На этой панели %d %s подписки (устройства мастер-ключей). Без панели ими некому управлять:\n", n, peersWord(n))
fmt.Println(" 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin")
fmt.Println(" 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)")
return strings.TrimSpace(c.ask(" Выбор [1]: ")) == "2"
}
// dropArea — снять всю область: для панели — пустым списком через sync, для legacy — по одному.
func (c *CLI) dropArea(area string) {
if area == "legacy" {
peers, err := c.listPeers()
if err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
n := 0
for _, p := range peers {
if areaOf(p.owner) != "legacy" {
continue
}
if _, err := c.ctrlCall("DELETE", "/api/peers/"+b64url.EncodeToString(stdB64(p.pub)), nil); err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
n++
}
fmt.Printf(" %s Снято пиров без области: %d\n", green("✓"), n)
return
}
r, err := c.ctrlCall("PUT", "/api/peers/sync", jsjson.Object{{Key: "owner", Val: area}, {Key: "keep", Val: []any{}}})
if err == nil && r.status != 200 {
err = ctrlError(r)
}
if err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
fmt.Printf(" %s Снято пиров области %s: %s\n", green("✓"), area, jsString(get(r.data, "removed")))
}
func stdB64(s string) []byte {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return fromB64url(s)
}
return b
}
// jsString — `${v}` для вывода числа или строки из ответа.
func jsString(v any) string {
switch x := v.(type) {
case nil:
return "undefined"
case string:
return x
case float64:
return jsjson.Number(x)
}
return string(jsjson.MustMarshal(v))
}
func (c *CLI) peersCmd(sub string, hasSub bool, arg string, hasArg bool) {
own := c.localPanelID()
peers, err := c.listPeers()
if err != nil {
errorf(" awg-ctrl недоступен: %v", err)
os.Exit(1)
}
var order []string
areas := map[string][]ctrlPeer{}
for _, p := range peers {
if a := areaOf(p.owner); a != "" {
if _, seen := areas[a]; !seen {
order = append(order, a)
}
areas[a] = append(areas[a], p)
}
}
if !hasSub {
if len(areas) == 0 {
fmt.Println(" Пиров подписки (sen://) на этом сервере нет")
return
}
fmt.Print("\n Пиры подписки (sen://) на этом сервере:\n\n")
for _, a := range order {
list := areas[a]
mark := ""
if a == own {
mark = "эта панель"
} else if a == "legacy" {
mark = "без области (выданы до областей)"
}
online := 0
for _, p := range list {
if p.online {
online++
}
}
fmt.Printf(" %s %4d %-5s онлайн %d %s\n", bold(fmt.Sprintf("%-16s", a)), len(list), peersWord(len(list)), online, dim(mark))
}
fmt.Printf("\n %s\n", dim("Снять область целиком: awg-ctrl peers drop <область>"))
return
}
if sub != "drop" || !hasArg || arg == "" || !(arg == "legacy" || reArea.MatchString(arg)) {
errorf(" Usage: awg-ctrl peers [drop <область|legacy>]")
os.Exit(1)
}
n := len(areas[arg])
if n == 0 {
fmt.Printf(" В области %s пиров нет\n", arg)
return
}
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
if arg == own && c.isRunning("ui") {
errorf(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней")
os.Exit(1)
}
fmt.Println(" " + dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —"))
fmt.Println(" " + dim("сперва удалите сервер в ней (или мастер-ключи)."))
if !c.confirmYes(fmt.Sprintf(" Снять %d %s области %s? Их устройства потеряют доступ к этому серверу.", n, peersWord(n), arg)) {
return
}
c.dropArea(arg)
}
// ── Логин и пароль панели ───────────────────────────────────────────────────
func (c *CLI) setCredentials(field string) {
var user, pass *string
if field == "" || field == "user" {
cur := env("UI_USER", "admin")
u := strings.TrimSpace(c.ask(fmt.Sprintf("\n Логин [%s]: ", cur)))
if u == "" {
u = cur
}
user = &u
}
if field == "" || field == "pass" {
b := make([]byte, 9)
rand.Read(b)
generated := b64url.EncodeToString(b)[:12]
p := strings.TrimSpace(c.ask(fmt.Sprintf(" Пароль [%s]: ", generated)))
if p == "" {
p = generated
}
pass = &p
}
content := c.readEnvFile()
if user != nil {
content = updateEnvVar(content, "UI_USER", *user)
os.Setenv("UI_USER", *user)
}
if pass != nil {
content = updateEnvVar(content, "UI_PASS", *pass)
os.Setenv("UI_PASS", *pass)
}
c.writeEnvFile(content)
if user != nil {
fmt.Printf("\n %s Логин: %s\n", green("✓"), *user)
}
if pass != nil {
fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass)
}
fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу."))
}
// ── Интерактивное меню ──────────────────────────────────────────────────────
func clearScreen() {
if st, err := os.Stdout.Stat(); err == nil && st.Mode()&os.ModeCharDevice != 0 {
fmt.Print("\x1b[1;1H\x1b[0J")
}
}
func (c *CLI) interactiveMenu() {
printMenu := func() {
clearScreen()
var parts []string
for _, p := range []string{c.brand, c.channel, c.version} {
if p != "" {
parts = append(parts, p)
}
}
fmt.Printf("\n%s\n\n", bold(cBlue+"── "+strings.Join(parts, " ")+" ──"+cReset))
for _, n := range c.all {
fmt.Println(c.serviceLine(n, true))
}
sep := dim("──────────────")
fmt.Printf(`
%s Запустить
%s Остановить
%s Перезапустить
%s Статус
%s
%s Сменить логин UI
%s Сменить пароль UI
%s
%s Выход
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("0"))
}
for {
printMenu()
fmt.Print(" › ")
choice, err := c.in.ReadString('\n')
if err != nil && choice == "" {
fmt.Println()
return // stdin закрыт
}
choice = strings.TrimSpace(choice)
fmt.Println()
switch choice {
case "1":
c.startAll()
case "2":
c.stopAll()
case "3":
c.restartAll()
case "4":
c.status()
case "5":
c.setCredentials("user")
case "6":
c.setCredentials("pass")
case "0":
os.Exit(0)
default:
fmt.Println(grey(" Неверный выбор"))
}
c.ask("\n " + dim("Enter для продолжения..."))
}
}
+292
View File
@@ -0,0 +1,292 @@
package cli
// `migrate export|import|cancel` — перенос панели с сервера «панель + VPN» на отдельную панель
// без VPN; архив и раскладка — internal/migrate (их же зовёт вкладка «Перенос»):
//
// старый: migrate export → архив: ui.db, ключи подписки и хаба, порты
// новый: migrate import → раскладывает архив, заводит ноду #0 и печатает join-строку
// старый: join '<строка>' → становится нодой #0 и поднимает ретранслятор прежних портов
import (
"crypto/rand"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func envPort(k string) int {
n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
if err != nil || n <= 0 || n > 65535 {
return 0
}
return n
}
func (c *CLI) paths() migrate.Paths {
return migrate.PathsFrom(func(env string) string { return c.svcEnv("ui", env) })
}
func (c *CLI) migrateCmd(sub, arg string, hasArg bool) {
switch sub {
case "export":
c.migrateExport(arg, hasArg)
case "import":
if !hasArg {
errorf(" Usage: awg-ctrl migrate import <архив>")
os.Exit(1)
}
c.migrateImport(arg)
case "cancel":
c.migrateCancel()
default:
errorf(" Usage: awg-ctrl migrate <export [файл]|import <архив>|cancel>")
os.Exit(1)
}
}
// ── export (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateExport(out string, hasOut bool) {
if c.isNode {
errorf(" Это нода: её панель — на core, переносить отсюда нечего")
os.Exit(1)
}
if !contains(c.svcList(), "awgctrl") {
errorf(" У этой панели нет своего VPN (LOCAL_NODE=off) — перенос рассчитан на сервер «панель + VPN»")
os.Exit(1)
}
dbFile := c.paths()["ui.db"]
if !exists(dbFile) {
errorf(" Нет базы панели: %s", dbFile)
os.Exit(1)
}
if !hasOut {
out = fmt.Sprintf("forgetting-migrate-%s.tar.gz", time.Now().Format("20060102-150405"))
}
fmt.Println()
fmt.Println(" Перенос панели на отдельный сервер. Сейчас:")
fmt.Println(" · панель и подписка sen:// здесь остановятся и больше не запустятся сами;")
fmt.Println(" · VPN продолжит работать: пиры и awg-ctrl не трогаются;")
fmt.Println(" · архив с базой панели и ключами подписки — " + bold(out))
if !c.confirmYes(" Продолжить?") {
return
}
c.stop("ui")
// Флаг — до снимка: перезапуск между ними поднял бы панель с базой, которая уже уехала.
content := updateEnvVar(c.readEnvFile(), "PANEL_MOVED", "on")
if err := c.writeEnvFile(content); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
if err := c.writeExport(out, dbFile); err != nil {
errorf(" Архив не создан: %v", err)
c.writeEnvFile(removeEnvVar(c.readEnvFile(), "PANEL_MOVED"))
os.Remove(out)
c.start("ui")
os.Exit(1)
}
fmt.Printf("\n %s Архив: %s %s\n", green("✓"), out, dim("(в нём ключ подписи — храните как пароль, после переноса удалите)"))
fmt.Println("\n Дальше:")
fmt.Println(" 1. Новая панель: install.sh, роль «только панель»")
fmt.Println(" 2. Скопируйте архив туда: " + dim("scp "+out+" root@<новая-панель>:"))
fmt.Println(" 3. На новой панели: " + bold("awg-ctrl migrate import "+filepath.Base(out)))
fmt.Println(" 4. Здесь — строку, которую она напечатает: " + bold("awg-ctrl join '<строка>'"))
fmt.Println(" " + dim("Передумали до шага 4: awg-ctrl migrate cancel"))
}
// svcList — сервисы роли без поправки на PANEL_MOVED и ретранслятор.
func (c *CLI) svcList() []string {
return without(without(c.all, "relay"), "ui")
}
func (c *CLI) writeExport(out, dbFile string) error {
db, err := migrate.OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
name, ip := "VPN", os.Getenv("SERVER_IP")
if h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health"); h != nil {
name = strOr(get(h, "server"), name)
if ip == "" {
ip = strOr(get(h, "ip"), "")
}
}
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
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")))
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int) 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},
}
}
// ── import (новая панель) ──────────────────────────────────────────────────
func (c *CLI) migrateImport(file string) {
if c.isNode {
errorf(" Это нода — импортировать панель нужно на core")
os.Exit(1)
}
if contains(c.svcList(), "awgctrl") {
errorf(" У этой панели свой VPN (LOCAL_NODE=on). Импорт — на панель без VPN: install.sh, роль «только панель»")
os.Exit(1)
}
f, err := os.Open(file)
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
b, err := migrate.Read(f)
f.Close()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer b.Close()
paths := c.paths()
if n, err := migrate.PanelData(paths["ui.db"]); err != nil || n > 0 {
errorf(" На этой панели уже есть данные (%s) — импорт их затёр бы. Нужна чистая панель.", paths["ui.db"])
os.Exit(1)
}
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
for host == "" || !reJoinHost.MatchString(host) {
host = strings.TrimSpace(c.ask(" Публичный адрес этой панели (IP или домен): "))
if host == "" {
return
}
}
plan, err := migrate.NewPlan(b, envPort, envPort("UI_PORT"), host)
if err != nil {
errorf(" %v (%s)", err, c.envFile)
os.Exit(1)
}
fmt.Println()
from := b.Str("server_ip")
if v := b.Str("version"); v != "" {
from += ", версия " + v
}
fmt.Printf(" Импорт панели «%s» (%s)\n", b.Str("server_name"), from)
fmt.Printf(" · подписка :%d, хаб нод :%d, адрес панели %s\n", plan.SubPort, plan.NodePort, host)
fmt.Println(" · прежние файлы панели уйдут в *.bak-<время>")
if !b.Has["sub_sign.key"] {
fmt.Println(" · " + dim("ключа подписи в архиве нет — на старом сервере подписка не была настроена"))
}
if !c.confirmYes(" Продолжить?") {
return
}
c.stop("ui")
secret := make([]byte, 16)
rand.Read(secret)
if err := migrate.Install(b, paths); err != nil {
errorf(" %v", err)
os.Exit(1)
}
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
errorf(" ui.db: %v", err)
os.Exit(1)
}
content := c.readEnvFile()
for _, kv := range plan.Env {
content = updateEnvVar(content, kv[0], kv[1])
}
if err := c.writeEnvFile(content); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
c.restartFresh()
// Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте.
var pin string
for range 50 {
if pin, err = migrate.CertPin(paths["node_tls.crt"]); err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
errorf(" Нет сертификата хаба %s — проверьте лог awg-ui и выдайте строку в панели (сервер #0)", paths["node_tls.crt"])
os.Exit(1)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
fmt.Printf("\n %s Панель перенесена. На старом сервере выполните (строка действует час):\n\n", green("✓"))
fmt.Println(" awg-ctrl join '" + join + "'")
fmt.Println()
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
}
// ── cancel (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateCancel() {
if !c.panelMoved() {
fmt.Println(" Переноса не было: панель на месте")
return
}
if c.isNode {
errorf(" Сервер уже нода новой панели — база там, и вернуть её сюда так нельзя.")
errorf(" Если всё же нужно: awg-ctrl unjoin, затем верните ui.db с новой панели вручную.")
os.Exit(1)
}
content := c.readEnvFile()
for _, k := range []string{"PANEL_MOVED", "RELAY"} {
content = removeEnvVar(content, k)
}
c.writeEnvFile(content)
fmt.Printf(" %s Перенос отменён, панель запускается здесь снова\n", green("✓"))
fmt.Println(" " + dim("Если архив уже импортирован на новую панель — не запускайте там ui, пока она не очищена"))
c.restartFresh()
}
// ── rehome (нода) ──────────────────────────────────────────────────────────
// rehome — панель переехала на новый адрес: тот же pin и id, другой CORE_HOST[:CORE_PORT].
func (c *CLI) rehome(addr string, hasAddr bool) {
if !c.isNode {
errorf(" Это не нода — менять адрес core нечему")
os.Exit(1)
}
host, port := addr, ""
if h, p, err := net.SplitHostPort(addr); err == nil {
host, port = h, p
}
if p, err := strconv.Atoi(port); !hasAddr || !reJoinHost.MatchString(host) || port != "" && (err != nil || p <= 0 || p > 65535) {
errorf(" Usage: awg-ctrl rehome <хост[:порт]> (адрес новой панели; pin и id ноды остаются)")
os.Exit(1)
}
content := updateEnvVar(c.readEnvFile(), "CORE_HOST", host)
if port != "" {
content = updateEnvVar(content, "CORE_PORT", port)
} else {
port = os.Getenv("CORE_PORT")
}
// Адрес, указанный руками, главнее того, что сообщила панель.
c.writeEnvFile(removeEnvVar(content, "CORE_ENDPOINT"))
fmt.Printf(" %s Core: %s:%s\n", green("✓"), host, port)
c.restartFresh()
}
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func decodeJoin(t *testing.T, s string) jsjson.Object {
t.Helper()
v, err := jsjson.Decode(fromB64url(strings.TrimPrefix(s, "awgjoin://")))
o, ok := v.(jsjson.Object)
if err != nil || !ok {
t.Fatalf("join-строка не разбирается: %v", err)
}
return o
}
// export берёт пути и порты из окружения сервисов, как их видит awg-ui.
func TestWriteExportFromEnv(t *testing.T) {
dir := t.TempDir()
sign := filepath.Join(dir, "sign")
os.WriteFile(sign, []byte("sign"), 0o600)
dbFile := filepath.Join(dir, "ui.db")
db, err := migrate.OpenDB(dbFile)
if err != nil {
t.Fatal(err)
}
db.Exec("CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
db.Exec("INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')")
db.Close()
for k, v := range map[string]string{
"AWG_ROOT": dir, "UI_DB_FILE": dbFile, "SUB_SIGN_KEY_FILE": sign, "AWGCTRL_PORT": "1", "SERVER_IP": "198.51.100.1",
"SUB_PORT": "40123", "SUB_TLS": "on", "NODE_PORT": "40500", "UI_PORT": "33000",
"SUB_TLS_KEY_FILE": filepath.Join(dir, "нет"), "SUB_TLS_CERT_FILE": filepath.Join(dir, "нет"),
"NODE_TLS_KEY_FILE": filepath.Join(dir, "нет"), "NODE_TLS_CERT_FILE": filepath.Join(dir, "нет"),
} {
t.Setenv(k, v)
}
out := filepath.Join(dir, "bundle.tar.gz")
if err := New().writeExport(out, dbFile); err != nil {
t.Fatal(err)
}
if st, _ := os.Stat(out); st.Mode().Perm() != 0o600 {
t.Errorf("права архива %v — в нём ключ подписи", st.Mode().Perm())
}
f, _ := os.Open(out)
b, err := migrate.Read(f)
f.Close()
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Str("server_ip") != "198.51.100.1" || b.Str("server_name") != "VPN" ||
b.Num("sub_port") != 40123 || b.Num("node_port") != 40500 || b.Num("ui_port") != 33000 || b.Meta.Get2("sub_tls") != true ||
!b.Has["sub_sign.key"] || b.Has["sub_tls.key"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
}
func TestRelayRoutes(t *testing.T) {
j := decodeJoin(t, migrate.JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123))
t.Setenv("SUB_PORT", "40123")
t.Setenv("NODE_PORT", "40400")
t.Setenv("UI_PORT", "32000")
if got, want := relayRoutes("203.0.113.7", 40500, j), "40123=203.0.113.7:40123,40400=203.0.113.7:40500,32000=203.0.113.7:33100"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
// Строка, выданная панелью заново (без ui/sub): подписка — на тот же порт, панель не пересылаем.
t.Setenv("NODE_PORT", "")
plain := decodeJoin(t, migrate.JoinString("2001:db8::7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 0, 0))
if got, want := relayRoutes("2001:db8::7", 40500, plain), "40123=[2001:db8::7]:40123"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
}
+265
View File
@@ -0,0 +1,265 @@
package cli
// `awg-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор» (порт
// cli/src/monitor.ts). Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не
// ставит, она только просит (через awg-ctrl) или показывает эту команду. Всё слушает
// 127.0.0.1: наружу Prometheus не торчит, а панель читает его по HTTP на той же машине.
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
const (
awgDir = "/etc/amnezia/amneziawg"
promDir = "/etc/prometheus"
promConf = promDir + "/prometheus.yml"
promToken = promDir + "/forgetting.token"
monitorHashFile = awgDir + "/monitor_token.sha256" // хэш локального токена: его читает awg-ui
promDefaults = "/etc/default/prometheus"
promMarker = "# managed by forgetting"
promListen = "127.0.0.1:9090"
promRetention = "31d" // с запасом на вид «30 дней» во вкладке «Монитор»
)
func mlog(s string) { fmt.Println(" " + s) }
func mfail(s string) {
fmt.Fprintf(os.Stderr, "\n ✗ %s\n\n", s)
os.Exit(1)
}
type shResult struct {
ok bool
output string
}
// sh — команда без stdin; quiet — вывод забираем себе, иначе он идёт в терминал.
func sh(quiet bool, name string, args ...string) shResult {
cmd := exec.Command(name, args...)
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
if quiet {
out, err := cmd.CombinedOutput()
return shResult{err == nil, string(out)}
}
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return shResult{cmd.Run() == nil, ""}
}
var (
reOSID = regexp.MustCompile(`(?m)^ID=(.*)$`)
reOSIDLike = regexp.MustCompile(`(?m)^ID_LIKE=(.*)$`)
reDebian = regexp.MustCompile(`(?i)debian|ubuntu`)
rePromVer = regexp.MustCompile(`version (\d+)\.(\d+)`)
reArgsLine = regexp.MustCompile(`(?m)^ARGS=.*$`)
)
func osFamily() string {
raw, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
val := func(re *regexp.Regexp) string {
if m := re.FindStringSubmatch(string(raw)); m != nil {
return strings.ReplaceAll(m[1], `"`, "")
}
return ""
}
return val(reOSID) + " " + val(reOSIDLike)
}
func promReady() bool {
cl := &http.Client{Timeout: 1500 * time.Millisecond, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get("http://" + promListen + "/-/ready")
if err != nil {
return false
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp.StatusCode == 200
}
func promInstalled() bool { return sh(true, "dpkg", "-s", "prometheus").ok }
func promActive() bool { return sh(true, "systemctl", "is-active", "--quiet", "prometheus").ok }
func promManaged() bool {
raw, err := os.ReadFile(promConf)
return err == nil && strings.Contains(string(raw), promMarker)
}
// authBlock — новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран.
func authBlock() string {
r := sh(true, "prometheus", "--version")
maj, min := 2, 0
if m := rePromVer.FindStringSubmatch(r.output); m != nil {
maj, _ = strconv.Atoi(m[1])
min, _ = strconv.Atoi(m[2])
}
if maj > 2 || maj == 2 && min >= 26 {
return " authorization:\n credentials_file: " + promToken + "\n"
}
return " bearer_token_file: " + promToken + "\n"
}
// backupStamp — new Date().toISOString() с «:» и «.» → «-».
func backupStamp() string {
return strings.NewReplacer(":", "-", ".", "-").Replace(time.Now().UTC().Format("2006-01-02T15:04:05.000Z"))
}
func copyFile(src, dst string) {
if raw, err := os.ReadFile(src); err == nil {
os.WriteFile(dst, raw, 0o644)
}
}
func writePromConfig(uiPort string) {
if exists(promConf) {
copyFile(promConf, promConf+".bak-"+backupStamp())
}
os.WriteFile(promConf, []byte(promMarker+`
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: forgetting
scheme: http
metrics_path: /metrics
`+authBlock()+` static_configs:
- targets: ['127.0.0.1:`+uiPort+`']
`), 0o644)
}
// writePromToken — токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш.
func writePromToken() {
b := make([]byte, 24)
rand.Read(b)
token := "awgm_" + b64url.EncodeToString(b)
os.WriteFile(promToken, []byte(token), 0o640)
sh(true, "chown", "root:prometheus", promToken)
sum := sha256.Sum256([]byte(token))
os.WriteFile(monitorHashFile, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
}
func setPromArgs() {
line := `ARGS="--web.listen-address=` + promListen + ` --storage.tsdb.retention.time=` + promRetention + `"`
raw, _ := os.ReadFile(promDefaults)
text := string(raw)
if text != "" {
copyFile(promDefaults, promDefaults+".bak-"+backupStamp())
}
if loc := reArgsLine.FindStringIndex(text); loc != nil {
text = text[:loc[0]] + line + text[loc[1]:]
} else {
text = strings.TrimSuffix(text, "\n") + "\n" + line + "\n"
}
os.WriteFile(promDefaults, []byte(text), 0o644)
}
func (c *CLI) monitorInstall() {
if os.Getuid() != 0 {
mfail("Нужен root: sudo awg-ctrl monitor install")
}
if !reDebian.MatchString(osFamily()) {
mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).")
}
uiPort := os.Getenv("UI_PORT")
if uiPort == "" {
mfail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.")
}
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
if promInstalled() && exists(promConf) && !promManaged() {
mfail("Prometheus уже установлен и настроен не нами (" + promConf + "). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.")
}
mlog("1/4 установка пакета prometheus")
if !promInstalled() {
if !sh(false, "apt-get", "update", "-qq").ok {
mfail("apt-get update завершился ошибкой")
}
if !sh(false, "apt-get", "install", "-y", "--no-install-recommends", "prometheus").ok {
mfail("apt-get install prometheus завершился ошибкой")
}
} else {
mlog("уже установлен")
}
mlog("2/4 токен и конфиг")
writePromToken()
writePromConfig(uiPort)
mlog("3/4 слушаем только " + promListen + ", хранение " + promRetention)
setPromArgs()
mlog("4/4 запуск")
sh(true, "systemctl", "enable", "prometheus")
if !sh(false, "systemctl", "restart", "prometheus").ok {
mfail("prometheus не запустился: journalctl -u prometheus")
}
for range 30 {
if promReady() {
fmt.Printf("\n ✓ Prometheus работает на %s, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n\n", promListen)
return
}
time.Sleep(time.Second)
}
mfail("Prometheus не ответил за 30 с: journalctl -u prometheus")
}
func yesNo(b bool, yes, no string) string {
if b {
return yes
}
return no
}
func monitorStatus() {
ok := promReady()
fmt.Println(" пакет: " + yesNo(promInstalled(), "установлен", "нет"))
fmt.Println(" сервис: " + yesNo(promActive(), "работает", "остановлен"))
fmt.Println(" наш конфиг: " + yesNo(promManaged(), "да", "нет"))
fmt.Println(" готов: " + yesNo(ok, "да, "+promListen, "нет"))
}
func monitorRemove() {
if os.Getuid() != 0 {
mfail("Нужен root: sudo awg-ctrl monitor remove")
}
// Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили.
if promInstalled() && exists(promConf) && !promManaged() {
mfail("Prometheus настроен не нами (" + promConf + "), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus")
}
mlog("1/3 остановка сервиса")
sh(true, "systemctl", "disable", "--now", "prometheus")
mlog("2/3 токен")
os.Remove(promToken)
os.Remove(monitorHashFile)
mlog("3/3 удаление пакета и накопленных данных")
if promInstalled() && !sh(false, "apt-get", "purge", "-y", "prometheus").ok {
mfail("apt-get purge prometheus завершился ошибкой")
}
fmt.Print("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n\n")
}
func (c *CLI) monitorCmd(sub string) {
switch sub {
case "install":
c.monitorInstall()
case "status":
monitorStatus()
case "remove":
monitorRemove()
default:
fmt.Println("Usage: awg-ctrl monitor <install|status|remove>")
os.Exit(1)
}
}
+13 -7
View File
@@ -8,6 +8,8 @@ import (
"strings"
"sync"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
@@ -29,12 +31,13 @@ type metricsPeer struct {
Rx float64 `json:"rx"`
Tx float64 `json:"tx"`
Priority string `json:"priority"`
Torrent bool `json:"torrent"`
}
type metricsHost struct {
CPU map[string]float64 `json:"cpu"`
Cores int `json:"cores"`
Load []float64 `json:"load"`
CPU jsjson.Object `json:"cpu"` // порядок режимов как в /proc/stat (и в Node-версии)
Cores int `json:"cores"`
Load []float64 `json:"load"`
Mem struct {
Total float64 `json:"total"`
Available float64 `json:"available"`
@@ -63,6 +66,7 @@ type metricsBody struct {
TxBytes float64 `json:"tx_bytes"`
Link linkStats `json:"link"`
Shaping ShapingStatus `json:"shaping"`
Torrent TorrentStatus `json:"torrent"`
Host *metricsHost `json:"host"`
List []metricsPeer `json:"list"`
}
@@ -89,13 +93,13 @@ func readHost() *metricsHost {
if len(first) < 2 {
return nil
}
h := &metricsHost{CPU: map[string]float64{}, Cores: runtime.NumCPU()}
h := &metricsHost{Cores: runtime.NumCPU()}
for i, m := range cpuModes {
v := 0.0
if i+1 < len(first) {
v = orZero(jsNumber(first[i+1]))
}
h.CPU[m] = v / 100 // USER_HZ = 100 на всех обычных ядрах
h.CPU = append(h.CPU, jsjson.KV{Key: m, Val: v / 100}) // USER_HZ = 100 на всех обычных ядрах
}
mem, err := os.ReadFile("/proc/meminfo")
if err != nil {
@@ -125,6 +129,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
if st.Up {
stats = s.getPeersData()
}
torrent := s.torrent.SeenSet() // до запроса: внутри цикла по строкам — ни базы, ни nft
rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''")
if err != nil {
return nil, err
@@ -137,7 +142,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
return nil, err
}
ps := stats[pub]
b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip)})
b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip), torrent[ip]})
}
rows.Close()
devs, err := s.peersAll()
@@ -146,7 +151,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
}
for _, p := range devs {
ps := stats[p.PubKey]
b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP)})
b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP), torrent[p.IP]})
}
b.Peers.Total = st.Peers
for _, ps := range stats {
@@ -161,6 +166,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
s.linkCounter("rx_dropped"), s.linkCounter("tx_dropped"), s.linkCounter("rx_errors"), s.linkCounter("tx_errors"),
}
b.Shaping = s.shaping.Status()
b.Torrent = s.torrent.Status()
b.Host = readHost()
return b, nil
}
+37
View File
@@ -0,0 +1,37 @@
package ctrl
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/maeneko/forgetting/internal/logx"
)
// SQLite открыт с одним соединением: любой запрос в базу изнутри цикла по строкам users ждал
// бы его вечно и намертво вешал awg-ctrl (так было в 0.4.2 с меткой торрента). Скрейп метрик
// идёт раз в 15 с, поэтому зависание проявлялось сразу.
func TestCollectMetricsNoDeadlock(t *testing.T) {
db, err := openDB(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES (?, ?, ?, 'vpn://x')", fmt.Sprintf("u%d", i), fmt.Sprintf("10.9.0.%d", i+2), fmt.Sprintf("pk%d", i))
}
s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())}
s.shaping = newShaping(s)
s.torrent = newTorrent(s)
done := make(chan error, 1)
go func() { _, err := s.collectMetrics(); done <- err }()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("collectMetrics завис: запрос в базу изнутри цикла по строкам")
}
}
+20 -2
View File
@@ -73,6 +73,7 @@ func (s *Server) routes() []route {
}),
R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) {
torrent := s.torrent.SeenSet()
rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''")
if err != nil {
return result{}, err
@@ -87,7 +88,8 @@ func (s *Server) routes() []route {
}
ps := stats[pub]
list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online},
{"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)}})
{"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)},
{"torrent", torrent[ip]}})
}
rows.Close()
return ok(ob{{"users", list}})
@@ -137,6 +139,7 @@ func (s *Server) routes() []route {
}),
R("GET", "/api/peers", true, a, func(c *rctx) (result, error) {
torrent := s.torrent.SeenSet()
stats := s.getPeersData()
peers, err := s.peersAll()
if err != nil {
@@ -147,7 +150,7 @@ func (s *Server) routes() []route {
ps := stats[p.PubKey]
list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner},
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx},
{"priority", s.shaping.PriorityOf(p.IP)}})
{"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", torrent[p.IP]}})
}
return ok(ob{{"peers", list}})
}),
@@ -357,6 +360,21 @@ func (s *Server) routes() []route {
return ok(s.shaping.Status())
}),
// Блокировка BitTorrent. Тоже под /api/ и через PUT — чтобы проходила через awg-agent на ноде.
R("GET", "/api/torrent", false, a, func(c *rctx) (result, error) { return ok(s.torrent.Status()) }),
R("PUT", "/api/torrent", false, a, func(c *rctx) (result, error) {
enabled, isBool := c.obj().Get2("enabled").(bool)
if !isBool {
return result{}, httpErr(400, "enabled должен быть true/false")
}
// nft не нашёлся или ядро отказало: оператору нужна причина, а не 500.
if err := s.torrent.Update(TorrentSettings{Enabled: enabled}); err != nil {
return result{}, httpErr(500, err.Error())
}
return ok(s.torrent.Status())
}),
R("GET", "/api/metrics", false, a, func(c *rctx) (result, error) {
d, err := s.metricsCached()
if err != nil {
+3
View File
@@ -55,6 +55,7 @@ type Server struct {
c Config
shaping *Shaping
torrent *Torrent
metrics metricsCache
monitor monitorJob
cliPath string
@@ -155,6 +156,7 @@ func New() (*Server, error) {
}
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
s.shaping = newShaping(s)
s.torrent = newTorrent(s)
return s, nil
}
@@ -182,5 +184,6 @@ func (s *Server) Run() error {
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
go s.shaping.Apply()
s.shaping.Start()
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
return <-errc
}
+55 -26
View File
@@ -20,6 +20,7 @@ import (
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
// с меткой «торренты» — туда же, пока метка висит (см. torrent.go), независимо от перегрузки
//
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому Apply зовётся на
@@ -123,6 +124,7 @@ type Shaping struct {
histOrder []string
totals []tick
heavy *orderedSet
applied *orderedSet // что сейчас стоит в nft-наборе: тяжёлые ∪ с меткой торрента
congested bool
lastRateDn float64
lastRateUp float64
@@ -138,7 +140,7 @@ func newShaping(s *Server) *Shaping {
sampleS := envNum("SHAPE_SAMPLE_S", 10)
windowS := envNum("SHAPE_WINDOW_S", 600)
return &Shaping{
s: s, prev: map[string][2]float64{}, history: map[string][]tick{}, heavy: newOrderedSet(),
s: s, prev: map[string][2]float64{}, history: map[string][]tick{}, heavy: newOrderedSet(), applied: newOrderedSet(),
sampleS: sampleS, windowTicks: int(math.Max(3, math.Round(windowS/sampleS))),
}
}
@@ -266,13 +268,12 @@ func (h *Shaping) applyNow() error {
return err
}
}
h.mu.Lock()
heavy := h.heavy.list()
h.mu.Unlock()
heavy := h.desired()
if err := h.nftApply(heavy); err != nil {
return err
}
h.mu.Lock()
h.applied = setOf(heavy)
h.lastError = ""
h.mu.Unlock()
h.s.log.Info("shaping applied", "down", st.DownMbit, "up", st.UpMbit, "overhead", oh, "heavy", len(heavy))
@@ -287,6 +288,7 @@ func (h *Shaping) removeNow() {
shExec("nft", "delete", "table", "inet", nftTable)
h.mu.Lock()
h.heavy.clear()
h.applied.clear()
h.history, h.histOrder, h.prev = map[string][]tick{}, nil, map[string][2]float64{}
h.totals = nil
h.congested = false
@@ -319,6 +321,50 @@ func (h *Shaping) Update(st ShapingSettings) error {
return err
}
// desired — кого держать в фоновом классе: тяжёлые (порядок сэмплера) и, следом, устройства с
// меткой «торренты». Метку ставит и снимает nft (таймаут), поэтому сверяемся каждый тик.
func (h *Shaping) desired() []string {
h.mu.Lock()
out := setOf(h.heavy.list())
h.mu.Unlock()
for _, ip := range h.s.torrent.SeenList() {
out.add(ip)
}
return out.list()
}
func setOf(list []string) *orderedSet {
o := newOrderedSet()
for _, k := range list {
o.add(k)
}
return o
}
// syncNft — привести nft-набор к desired(); без изменений nft не трогаем.
func (h *Shaping) syncNft() {
want := h.desired()
h.mu.Lock()
same := len(want) == h.applied.size()
for _, ip := range want {
same = same && h.applied.has(ip)
}
h.mu.Unlock()
if same {
return
}
h.opMu.Lock()
err := h.nftApply(want)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
return
}
h.mu.Lock()
h.applied = setOf(want)
h.mu.Unlock()
}
// ───────── сэмплер (RFC 6057) ─────────
func (h *Shaping) pushTick(arr []tick, t tick) []tick {
@@ -391,6 +437,8 @@ func (h *Shaping) tick() {
h.tickNo++
tickNo := h.tickNo
h.mu.Unlock()
// В конце любого тика, в том числе пока окно не набралось: метка торрента не ждёт 10 минут.
defer h.syncNft()
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
if tickNo%checkEvery == 0 && !h.hasCake(h.s.iface) {
@@ -443,18 +491,9 @@ func (h *Shaping) tick() {
h.lastRateUp = totalUp * 8 / dt / 1e6
if !st.Auto {
changed := h.heavy.size() > 0 || h.congested
h.heavy.clear()
h.congested = false
h.mu.Unlock()
if changed {
h.opMu.Lock()
err := h.nftApply(nil)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
return
}
if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем
@@ -507,21 +546,11 @@ func (h *Shaping) tick() {
}
}
}
var apply []string
if changed || wasCongested != h.congested {
h.heavy = next
apply = next.list()
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", apply)
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", next.list())
}
h.mu.Unlock()
if changed || wasCongested != h.congested {
h.opMu.Lock()
err := h.nftApply(apply)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
}
func (h *Shaping) Start() {
@@ -571,7 +600,7 @@ func (h *Shaping) qdiscStats(dev string) *QdiscStats {
func (h *Shaping) PriorityOf(ip string) string {
h.mu.Lock()
defer h.mu.Unlock()
if h.heavy.has(ip) {
if h.applied.has(ip) { // то, что реально стоит в nft: тяжёлые и с меткой торрента
return "background"
}
return "normal"
@@ -593,7 +622,7 @@ func (h *Shaping) Status() ShapingStatus {
devices++
}
}
heavy := h.heavy.list()
heavy := h.applied.list()
if heavy == nil {
heavy = []string{}
}
+310
View File
@@ -0,0 +1,310 @@
package ctrl
import (
"encoding/json"
"sort"
"strings"
"sync"
"time"
)
// Блокировка BitTorrent — то же, что sniffing + blackhole в Xray (3x-ui), только в ядре.
//
// Решение принимается по первым байтам и один раз на соединение: совпала сигнатура — соединение
// получает бит в ct mark и дальше режется целиком, в обе стороны. Смотрим только то, что
// клиенты шлют в интернет (iifname awg1), до NAT, поэтому источник — IP клиента в туннеле.
//
// Ловится: DHT (запросы и ответы), UDP-трекеры, начало uTP-соединения, а при поддержке @ih в
// nft/ядре — ещё открытое TCP-рукопожатие и HTTP-анонс. Не ловится шифрованное рукопожатие
// (MSE/PE) и HTTPS-трекеры — так же, как у Xray. Но без DHT и трекеров клиенту негде брать пиров.
//
// Как и шейпинг, ничего не пишет в awg1.conf: таблица ставится из процесса и идемпотентно.
// nft-таблица не привязана к интерфейсу (iifname — по имени), поэтому переживает его рестарт;
// после перезагрузки сервера её возвращает Apply на старте.
type TorrentSettings struct {
Enabled bool `json:"enabled"`
}
type TorrentPeer struct {
IP string `json:"ip"`
Name string `json:"name"` // имя пользователя; у sen://-устройства — пусто
}
type TorrentStatus struct {
Settings TorrentSettings `json:"settings"`
Active bool `json:"active"` // таблица реально стоит
TCP bool `json:"tcp"` // проверяется и TCP (нужен @ih: nftables ≥ 1.0, свежее ядро)
Hits float64 `json:"hits"` // заблокированных попыток с последнего применения
Seen []TorrentPeer `json:"seen"` // кто пытался за последние torrentSeenTTL
Error string `json:"error,omitempty"`
}
const (
torrentTable = "awg_torrent"
torrentMark = "0x00100000" // свой бит ct mark: чужие биты не трогаем
torrentSeenTTL = "10m"
torrentCacheS = 5 * time.Second // /api/users/stats опрашивается часто — nft не дёргаем на каждый
)
// Сигнатуры. @th,64 — начало данных UDP (заголовок 8 байт); @ih,0 — начало данных TCP с учётом опций.
const torrentUDPRules = `
udp dport 53 return
@th,64,96 0x64313a6164323a696432303a jump block
@th,64,96 0x64313a7264323a696432303a jump block
@th,64,96 0x000004172710198000000000 jump block
ct state new udp length >= 28 @th,64,8 0x41 @th,72,8 < 3 @th,128,32 0 jump block`
// DHT-запрос «d1:ad2:id20:» и ответ «d1:rd2:id20:» (bencode, ключи отсортированы — «a»/«r» первые);
// UDP-трекер: protocol_id 0x41727101980 + action connect (0); uTP SYN: тип 4, версия 1 (0x41),
// расширение 0–2, timestamp_difference 0 — только в новом потоке, где QUIC начинается с длинного
// заголовка (0xC0+), и не на 53-м порту, чтобы случайный ID DNS-запроса не сошёлся.
const torrentTCPRules = `
@ih,0,128 0x13426974546f7272656e742070726f74 jump block
@ih,0,112 0x474554202f616e6e6f756e63653f jump block`
// «\x13BitTorrent prot» — открытое рукопожатие пиров; «GET /announce?» — HTTP-трекер.
type Torrent struct {
s *Server
opMu sync.Mutex // сериализует nft
mu sync.Mutex
enabled bool // копия настройки в памяти: cached() зовут внутри циклов по строкам users, а
// соединение с SQLite одно — запрос в базу оттуда ждал бы его вечно
tcp bool
lastError string
cacheAt time.Time
cache map[string]bool
cacheHits float64
}
func newTorrent(s *Server) *Torrent {
t := &Torrent{s: s}
t.enabled = t.Settings().Enabled
return t
}
func (t *Torrent) Settings() TorrentSettings {
return TorrentSettings{Enabled: t.s.getCfg("torrentBlock", "0") == "1"}
}
func (t *Torrent) script(tcp bool) string {
iface := t.s.iface
var b strings.Builder
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
b.WriteString("\nadd table inet " + torrentTable + "\ndelete table inet " + torrentTable + "\ntable inet " + torrentTable + " {\n")
b.WriteString(" counter hits {\n }\n")
b.WriteString(" set seen {\n type ipv4_addr\n flags dynamic, timeout\n timeout " + torrentSeenTTL + "\n }\n")
b.WriteString(" chain block {\n ct mark set ct mark or " + torrentMark + "\n update @seen { ip saddr }\n counter name \"hits\"\n drop\n }\n")
b.WriteString(" chain udp_sig {" + torrentUDPRules + "\n }\n")
if tcp {
b.WriteString(" chain tcp_sig {" + torrentTCPRules + "\n }\n")
}
b.WriteString(" chain forward {\n type filter hook forward priority -10; policy accept;\n")
b.WriteString(" ct mark and " + torrentMark + " == " + torrentMark + " drop\n")
b.WriteString(" iifname \"" + iface + "\" meta nfproto ipv4 meta l4proto udp jump udp_sig\n")
if tcp {
b.WriteString(" iifname \"" + iface + "\" meta nfproto ipv4 meta l4proto tcp jump tcp_sig\n")
}
b.WriteString(" }\n}\n")
return b.String()
}
func (t *Torrent) applyNow() error {
if !t.Settings().Enabled {
t.removeNow()
return nil
}
// Сначала полный набор; старый nft/ядро не знают @ih — тогда без TCP, но UDP (DHT, трекеры,
// uTP) всё равно режем: это и есть основная часть.
tcp := true
err := shExecStdin("nft", []string{"-f", "-"}, t.script(true))
if err != nil {
tcp = false
if err2 := shExecStdin("nft", []string{"-f", "-"}, t.script(false)); err2 != nil {
return err2
}
t.s.log.Warn("torrent: TCP-сигнатуры не поддерживаются, блокируем только UDP", "nft", err.Error())
}
t.mu.Lock()
t.tcp, t.lastError, t.cache = tcp, "", nil
t.mu.Unlock()
t.s.log.Info("torrent block applied", "tcp", tcp)
return nil
}
func (t *Torrent) removeNow() {
shExec("nft", "delete", "table", "inet", torrentTable)
t.mu.Lock()
t.tcp, t.cache = false, nil
t.mu.Unlock()
}
func (t *Torrent) noteError(err error) {
t.mu.Lock()
defer t.mu.Unlock()
if m := err.Error(); m != t.lastError {
t.s.log.Warn("torrent: " + m)
t.lastError = m
}
}
// Apply не бросает: без nft awg-ctrl должен работать дальше (как шейпинг).
func (t *Torrent) Apply() {
t.opMu.Lock()
err := t.applyNow()
t.opMu.Unlock()
if err != nil {
t.noteError(err)
}
}
// Update сохраняет и применяет; ошибку отдаёт вызывающему — оператору нужна причина.
func (t *Torrent) Update(st TorrentSettings) error {
v := "0"
if st.Enabled {
v = "1"
}
t.s.setCfg("torrentBlock", v)
t.mu.Lock()
t.enabled, t.cache = st.Enabled, nil
t.mu.Unlock()
t.opMu.Lock()
err := t.applyNow()
t.opMu.Unlock()
if err != nil {
t.mu.Lock()
t.lastError = err.Error()
t.mu.Unlock()
}
return err
}
// read — набор seen и счётчик из ядра. ok=false — таблицы нет.
func (t *Torrent) read() (seen map[string]bool, hits float64, ok bool) {
out, err := shExec("nft", "-j", "list", "set", "inet", torrentTable, "seen")
if err != nil {
return nil, 0, false
}
seen = map[string]bool{}
var doc struct {
Nftables []struct {
Set *struct {
Elem []json.RawMessage `json:"elem"`
} `json:"set"`
Counter *struct {
Packets float64 `json:"packets"`
} `json:"counter"`
} `json:"nftables"`
}
if json.Unmarshal([]byte(out), &doc) == nil {
for _, it := range doc.Nftables {
if it.Set == nil {
continue
}
// Элемент с таймаутом — {"elem": {"val": "10.9.0.2", ...}}, без него — просто строка.
for _, raw := range it.Set.Elem {
var ip string
if json.Unmarshal(raw, &ip) != nil {
var e struct {
Elem struct {
Val string `json:"val"`
} `json:"elem"`
}
if json.Unmarshal(raw, &e) == nil {
ip = e.Elem.Val
}
}
if ip != "" {
seen[ip] = true
}
}
}
}
if out, err := shExec("nft", "-j", "list", "counter", "inet", torrentTable, "hits"); err == nil {
doc.Nftables = nil
if json.Unmarshal([]byte(out), &doc) == nil {
for _, it := range doc.Nftables {
if it.Counter != nil {
hits = it.Counter.Packets
}
}
}
}
return seen, hits, true
}
// cached не трогает базу (только память и nft) — его можно звать где угодно.
func (t *Torrent) cached() (map[string]bool, float64, bool) {
t.mu.Lock()
if !t.enabled {
t.mu.Unlock()
return nil, 0, false
}
if t.cache != nil && time.Since(t.cacheAt) < torrentCacheS {
c, h := t.cache, t.cacheHits
t.mu.Unlock()
return c, h, true
}
t.mu.Unlock()
seen, hits, ok := t.read()
if !ok {
return nil, 0, false
}
t.mu.Lock()
t.cache, t.cacheHits, t.cacheAt = seen, hits, time.Now()
t.mu.Unlock()
return seen, hits, true
}
// SeenSet — кто пытался открыть торрент-соединение за последние torrentSeenTTL. Берите его до
// запроса к базе, а не внутри цикла по строкам: так на открытом курсоре не висит вызов nft.
func (t *Torrent) SeenSet() map[string]bool {
seen, _, _ := t.cached()
out := make(map[string]bool, len(seen))
for ip := range seen {
out[ip] = true
}
return out
}
// SeenList — IP с меткой «торренты», по порядку; шейпинг держит их в фоновом классе.
func (t *Torrent) SeenList() []string {
seen, _, _ := t.cached()
out := make([]string, 0, len(seen))
for ip := range seen {
out = append(out, ip)
}
sort.Strings(out)
return out
}
func (t *Torrent) Status() TorrentStatus {
st := TorrentStatus{Settings: t.Settings(), Seen: []TorrentPeer{}}
seen, hits, active := t.cached()
t.mu.Lock()
st.TCP, st.Error = t.tcp && active, t.lastError
t.mu.Unlock()
st.Active, st.Hits = active, hits
if len(seen) == 0 {
return st
}
names := map[string]string{}
if rows, err := t.s.db.Query("SELECT name, ip FROM users"); err == nil {
for rows.Next() {
var n, ip string
if rows.Scan(&n, &ip) == nil {
names[ip] = n
}
}
rows.Close()
}
for ip := range seen {
st.Seen = append(st.Seen, TorrentPeer{IP: ip, Name: names[ip]})
}
sort.Slice(st.Seen, func(i, j int) bool { return st.Seen[i].IP < st.Seen[j].IP })
return st
}
+43
View File
@@ -0,0 +1,43 @@
// Package envfile — правка cli.env (KEY=value построчно): CLI и панель пишут в него одинаково.
package envfile
import (
"os"
"regexp"
"strings"
)
// Update — как String.replace без g: первое KEY=…, а нет его — строка в конец.
func Update(content, key, value string) string {
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
if loc := re.FindStringIndex(content); loc != nil {
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
}
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
}
func Remove(content, key string) string {
var kept []string
for _, l := range strings.Split(content, "\n") {
if !strings.HasPrefix(l, key+"=") {
kept = append(kept, l)
}
}
return strings.Join(kept, "\n")
}
// Set — задать set и убрать remove в файле. Новый файл — 0600 (в нём пароль панели).
func Set(file string, set [][2]string, remove ...string) error {
raw, err := os.ReadFile(file)
if err != nil && !os.IsNotExist(err) {
return err
}
content := string(raw)
for _, kv := range set {
content = Update(content, kv[0], kv[1])
}
for _, k := range remove {
content = Remove(content, k)
}
return os.WriteFile(file, []byte(content), 0o600)
}
+382
View File
@@ -0,0 +1,382 @@
// Package migrate — перенос панели с сервера «панель + VPN» на отдельную панель без VPN: архив
// (ui.db, ключи подписки и хаба, порты), его раскладка на новой панели и нода #0. Общий для
// CLI (`awg-control migrate`) и вкладки «Перенос» в панели. Порядок и смысл —
// docs/node-protocol.md, «Перенос панели».
//
// Старый сервер остаётся нодой #0: id сервера в ui.db не меняется, поэтому конфиг клиента
// (servers[] и ключи) тот же байт в байт, а пиры на его awg-ctrl уже принадлежат области
// перенесённой панели (sub_meta.panel_id едет вместе с ui.db).
package migrate
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64url = base64.RawURLEncoding
const (
Version = 1
maxFile = 1 << 30 // ui.db крупнее гигабайта — явно не то
JoinTTL = 3600 // как у панели: одноразовый секрет живёт час
)
// File — что едет в архиве: имя внутри архива и переменная пути в окружении awg-ui.
type File struct {
Name, Env string
Required bool
}
var Files = []File{
{"ui.db", "UI_DB_FILE", true},
// 🛑 Ключ подписи и TLS подписки зашиты во все выданные sen://, сертификат хаба — во все
// join-строки и подключённые ноды: без них перенос ломает и ссылки, и ноды.
{"sub_sign.key", "SUB_SIGN_KEY_FILE", false},
{"sub_tls.key", "SUB_TLS_KEY_FILE", false},
{"sub_tls.crt", "SUB_TLS_CERT_FILE", false},
{"node_tls.key", "NODE_TLS_KEY_FILE", false},
{"node_tls.crt", "NODE_TLS_CERT_FILE", false},
}
// Paths — имя файла в архиве → путь на этой машине.
type Paths map[string]string
// PathsFrom — пути из переменных окружения awg-ui (get(env) → путь).
func PathsFrom(get func(env string) string) Paths {
p := Paths{}
for _, f := range Files {
p[f.Name] = get(f.Env)
}
return p
}
// OpenDB — ui.db тем же драйвером, что у awg-ui.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", "file:"+(&url.URL{Path: path}).EscapedPath()+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// ── Архив ───────────────────────────────────────────────────────────────────
// Write — архив в w: снимок db (VACUUM INTO — целостный, вместе с WAL), файлы из p и meta
// (+ panel_id из базы). Отсутствующие необязательные файлы пропускаются.
func Write(w io.Writer, db *sql.DB, p Paths, meta jsjson.Object) error {
tmp, err := os.MkdirTemp("", "forgetting-migrate-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
snap := filepath.Join(tmp, "ui.db")
if _, err := db.Exec("VACUUM INTO ?", snap); err != nil {
return fmt.Errorf("снимок ui.db: %w", err)
}
var panelID string
db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&panelID)
meta = append(jsjson.Object{{Key: "v", Val: Version}, {Key: "panel_id", Val: panelID}}, meta...)
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
add := func(name string, data []byte) error {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
err = add("meta.json", jsjson.MustMarshal(meta))
for _, f := range Files {
if err != nil {
break
}
src := p[f.Name]
if f.Name == "ui.db" {
src = snap
}
raw, rerr := os.ReadFile(src)
if errors.Is(rerr, os.ErrNotExist) && !f.Required {
continue
}
if rerr != nil {
err = rerr
break
}
err = add(f.Name, raw)
}
if cerr := tw.Close(); err == nil {
err = cerr
}
if cerr := gz.Close(); err == nil {
err = cerr
}
return err
}
type Bundle struct {
Dir string
Meta jsjson.Object
Has map[string]bool
}
func (b *Bundle) Close() { os.RemoveAll(b.Dir) }
func (b *Bundle) Num(k string) int {
f, _ := b.Meta.Get2(k).(float64)
return int(f)
}
func (b *Bundle) Str(k string) string {
s, _ := b.Meta.Get2(k).(string)
return s
}
// Read — распаковка во временный каталог; в архиве допустимы только известные имена.
func Read(r io.Reader) (*Bundle, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("не архив переноса: %w", err)
}
dir, err := os.MkdirTemp("", "forgetting-import-")
if err != nil {
return nil, err
}
b := &Bundle{Dir: dir, Has: map[string]bool{}}
fail := func(err error) (*Bundle, error) { b.Close(); return nil, err }
allowed := map[string]bool{"meta.json": true}
for _, f := range Files {
allowed[f.Name] = true
}
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fail(fmt.Errorf("архив повреждён: %w", err))
}
if h.Typeflag != tar.TypeReg || !allowed[h.Name] || b.Has[h.Name] || h.Size > maxFile {
return fail(fmt.Errorf("в архиве лишнее: %q", h.Name))
}
dst, err := os.OpenFile(filepath.Join(dir, h.Name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
_, err = io.Copy(dst, io.LimitReader(tr, maxFile))
if cerr := dst.Close(); err == nil {
err = cerr
}
}
if err != nil {
return fail(err)
}
b.Has[h.Name] = true
}
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
var v any
if err == nil {
v, err = jsjson.Decode(raw)
}
meta, ok := v.(jsjson.Object)
if err != nil || !ok || meta.Get2("v") != float64(Version) || !b.Has["ui.db"] {
return fail(errors.New("не архив переноса или от другой версии (нужны meta.json v1 и ui.db)"))
}
b.Meta = meta
return b, nil
}
// PanelData — сколько в базе того, что перенос затёр бы (мастер-ключи, ноды, ключи API).
func PanelData(file string) (int, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
return 0, nil
}
db, err := OpenDB(file)
if err != nil {
return 0, err
}
defer db.Close()
return Count(db), nil
}
func Count(db *sql.DB) int {
total := 0
for _, t := range []string{"master_keys", "nodes", "api_keys"} {
var n int
// Таблицы может не быть — awg-ui ещё не запускался.
if db.QueryRow("SELECT COUNT(*) FROM "+t).Scan(&n) == nil {
total += n
}
}
return total
}
// ── Раскладка на новой панели ──────────────────────────────────────────────
// Plan — порты и переменные cli.env новой панели. Подписка и хаб — на прежних портах: они в
// ссылках, у нод и в DNS, если адрес — домен.
type Plan struct {
SubPort, NodePort, UIPort int
Host string
Env [][2]string
}
// 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}
if p.SubPort == 0 {
p.SubPort = env("SUB_PORT")
}
if p.NodePort == 0 {
p.NodePort = env("NODE_PORT")
}
if p.NodePort == 0 {
return nil, errors.New("приём нод не настроен (NODE_PORT): старый сервер подключится к панели нодой")
}
if p.UIPort != 0 && (p.UIPort == p.SubPort || p.UIPort == p.NodePort) || p.SubPort != 0 && p.SubPort == p.NodePort {
return nil, fmt.Errorf("порты пересекаются: панель %d, подписка %d, хаб %d — смените UI_PORT", p.UIPort, p.SubPort, p.NodePort)
}
for _, kv := range [][2]string{{"SUB_PORT", strconv.Itoa(p.SubPort)}, {"NODE_PORT", strconv.Itoa(p.NodePort)}, {"SUB_HOST", host}} {
if kv[1] != "0" && kv[1] != "" {
p.Env = append(p.Env, kv)
}
}
tlsOn := "off"
if b.Has["sub_tls.crt"] && b.Meta.Get2("sub_tls") == true {
tlsOn = "on"
}
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)})
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
if ip := b.Str("server_ip"); ip != "" {
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
return p, nil
}
// TrustRelay — ещё один адрес ретранслятора: старый сервер может выходить в сеть не с того IP,
// что у него в SERVER_IP (NAT, второй адрес). Панель видит, откуда пришёл архив, — его и берём.
func (p *Plan) TrustRelay(ip string) {
if ip == "" {
return
}
for i, kv := range p.Env {
if kv[0] == "RELAY_FROM" {
for _, have := range strings.Split(kv[1], ",") {
if have == ip {
return
}
}
p.Env[i][1] += "," + ip
return
}
}
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
// Install — файлы архива на свои места; прежние уходят в *.bak-<время>.
func Install(b *Bundle, p Paths) error {
stamp := time.Now().Format("20060102-150405")
for _, f := range Files {
if !b.Has[f.Name] {
continue
}
dst := p[f.Name]
olds := []string{dst}
if f.Name == "ui.db" {
olds = append(olds, dst+"-wal", dst+"-shm")
}
for _, o := range olds {
if _, err := os.Stat(o); err == nil {
if err := os.Rename(o, o+".bak-"+stamp); err != nil {
return fmt.Errorf("не удалось отложить %s: %w", o, err)
}
}
}
raw, err := os.ReadFile(filepath.Join(b.Dir, f.Name))
if err == nil {
err = os.MkdirAll(filepath.Dir(dst), 0o700)
}
if err == nil {
err = os.WriteFile(dst, raw, 0o600)
}
if err != nil {
return fmt.Errorf("не удалось записать %s: %w", dst, err)
}
}
return nil
}
// AddNodeZero — старый сервер в реестре нод под прежним id 0, ждёт первого подключения.
func AddNodeZero(dbFile, name string, secret []byte) error {
if name == "" || len([]rune(name)) > 40 || strings.ContainsAny(name, "\r\n<>") {
name = "VPN"
}
db, err := OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
h := sha256.Sum256(secret)
now := time.Now().Unix()
if _, err := db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, ?, ?, ?, ?)",
name, h[:], now+JoinTTL, now); err != nil {
return fmt.Errorf("нода #0 не заведена (база со старой версии? обновите старый сервер): %w", err)
}
return nil
}
// CertPin — SHA-256 SPKI сертификата, base64url: то же, что pin в join-строке.
func CertPin(file string) (string, error) {
raw, err := os.ReadFile(file)
if err != nil {
return "", err
}
blk, _ := pem.Decode(raw)
if blk == nil {
return "", errors.New("не PEM")
}
cert, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64url.EncodeToString(sum[:]), nil
}
// JoinString — как у панели (nodes.go), плюс ui/sub: куда ретранслятору старого сервера слать
// прежние порты панели и подписки. Остальные читатели строки лишних полей не видят.
func JoinString(host string, port int, pin string, id int64, secret []byte, uiPort, subPort int) string {
o := jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: port},
{Key: "pin", Val: pin}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}
if uiPort > 0 {
o = append(o, jsjson.KV{Key: "ui", Val: uiPort})
}
if subPort > 0 {
o = append(o, jsjson.KV{Key: "sub", Val: subPort})
}
return "awgjoin://" + b64url.EncodeToString(jsjson.MustMarshal(o))
}
+176
View File
@@ -0,0 +1,176 @@
package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testDB(t *testing.T, file string) {
t.Helper()
db, err := OpenDB(file)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, q := range []string{
"CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
"INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')",
"CREATE TABLE nodes (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pub TEXT, join_hash BLOB, join_expires INTEGER, created_at INTEGER NOT NULL, last_seen INTEGER, version TEXT)",
"CREATE TABLE master_keys (id INTEGER PRIMARY KEY)",
"INSERT INTO master_keys VALUES (1)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(err)
}
}
}
// Архив → раскладка: всё, что зашито в ссылки и у нод, едет как есть; база — целостным
// снимком, в котором новая панель заводит ноду #0.
func TestBundleRoundTrip(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
paths := func(dir string) Paths {
return PathsFrom(func(env string) string { return filepath.Join(dir, strings.ToLower(env)) })
}
from, to := paths(src), paths(dst)
testDB(t, from["ui.db"])
for name, v := range map[string]string{"sub_sign.key": "sign", "sub_tls.key": "tlskey", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
os.WriteFile(from[name], []byte(v), 0o600)
}
os.WriteFile(to["sub_sign.key"], []byte("прежний"), 0o600)
db, _ := OpenDB(from["ui.db"])
var buf bytes.Buffer
err := Write(&buf, db, from, jsjson.Object{{Key: "server_ip", Val: "198.51.100.1"}, {Key: "sub_port", Val: 40123},
{Key: "sub_tls", Val: true}, {Key: "node_port", Val: 40500}})
db.Close()
if err != nil {
t.Fatal(err)
}
b, err := Read(&buf)
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Num("sub_port") != 40123 || b.Has["node_tls.crt"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
if n, err := PanelData(to["ui.db"]); err != nil || n != 0 {
t.Errorf("PanelData(нет файла) = %d, %v", n, err)
}
plan, err := NewPlan(b, func(string) int { return 0 }, 33000, "203.0.113.7")
if err != nil {
t.Fatal(err)
}
got := regexp.MustCompile(`"MOVED_IN_AT","\d+"`).ReplaceAllString(string(jsjson.MustMarshal(plan.Env)), `"MOVED_IN_AT","…"`)
if got != `[["SUB_PORT","40123"],["NODE_PORT","40500"],["SUB_HOST","203.0.113.7"],["SUB_TLS","on"],["MOVED_IN_AT","…"],["RELAY_FROM","198.51.100.1"]]` {
t.Errorf("env: %s", got)
}
if _, err := NewPlan(b, func(string) int { return 0 }, 40500, "h"); err == nil {
t.Error("UI_PORT = порт хаба: ожидали ошибку")
}
if err := Install(b, to); err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{"sub_sign.key": "sign", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
if raw, _ := os.ReadFile(to[name]); string(raw) != want {
t.Errorf("%s: %q", name, raw)
}
}
if baks, _ := filepath.Glob(to["sub_sign.key"] + ".bak-*"); len(baks) != 1 {
t.Errorf("прежний ключ не отложен: %v", baks)
}
if n, _ := PanelData(to["ui.db"]); n != 1 {
t.Errorf("PanelData(снимок) = %d", n)
}
if err := AddNodeZero(to["ui.db"], "VPN\n<script>", []byte("0123456789abcdef")); err != nil {
t.Fatal(err)
}
db, _ = OpenDB(to["ui.db"])
var name string
db.QueryRow("SELECT name FROM nodes WHERE id = 0").Scan(&name)
db.Close()
if name != "VPN" {
t.Errorf("нода #0: %q", name)
}
if err := AddNodeZero(to["ui.db"], "VPN", []byte("x")); err == nil {
t.Error("вторая нода #0: ожидали ошибку")
}
}
func TestReadRejectsForeignEntries(t *testing.T) {
for name, entries := range map[string][]string{
"лишний файл": {"meta.json", "ui.db", "../../etc/passwd"},
"без базы": {"meta.json"},
"повтор": {"meta.json", "ui.db", "ui.db"},
} {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, e := range entries {
data := []byte("x")
if e == "meta.json" {
data = []byte(`{"v":1}`)
}
tw.WriteHeader(&tar.Header{Name: e, Mode: 0o600, Size: int64(len(data))})
tw.Write(data)
}
tw.Close()
gz.Close()
if b, err := Read(&buf); err == nil {
b.Close()
t.Errorf("%s: архив принят", name)
}
}
}
func TestJoinString(t *testing.T) {
s := JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123)
raw, _ := b64url.DecodeString(strings.TrimPrefix(s, "awgjoin://"))
// Поля сверх v1 — в конце: install.sh и старые CLI их не замечают.
if want := `{"v":1,"h":"203.0.113.7","p":40500,"pin":"` + strings.Repeat("A", 43) + `","n":0,"s":"MDEyMzQ1Njc4OWFiY2RlZg","ui":33100,"sub":40123}`; string(raw) != want {
t.Errorf("join: %s", raw)
}
}
func TestCodeAndSeal(t *testing.T) {
c := Code{Host: "2001:db8::7", Port: 33000, Key: NewKey()}
got, err := ParseCode(" " + c.String() + "\n")
if err != nil || got.Host != c.Host || got.Port != c.Port || !bytes.Equal(got.Key, c.Key) {
t.Fatalf("ParseCode: %+v %v", got, err)
}
if u := got.URL(); u != "http://[2001:db8::7]:33000/migrate/v1/import" {
t.Errorf("URL: %s", u)
}
for _, bad := range []string{"", "awgmove://", "awgjoin://e30", CodePrefix + "e30",
Code{Host: "h/../x", Port: 1, Key: c.Key}.String(), Code{Host: "h", Port: 0, Key: c.Key}.String(), Code{Host: "h", Port: 1, Key: []byte("short")}.String()} {
if _, err := ParseCode(bad); err == nil {
t.Errorf("ParseCode(%q): ожидали ошибку", bad)
}
}
box := SealRequest(c.Key, []byte("архив"))
if plain, err := OpenRequest(c.Key, box); err != nil || string(plain) != "архив" {
t.Errorf("OpenRequest: %q %v", plain, err)
}
if _, err := OpenResponse(c.Key, box); err == nil {
t.Error("запрос открылся как ответ")
}
if _, err := OpenRequest(NewKey(), box); err == nil {
t.Error("открылся чужим ключом")
}
box[len(box)-1] ^= 1
if _, err := OpenRequest(c.Key, box); err == nil {
t.Error("открылся повреждённый")
}
}
+115
View File
@@ -0,0 +1,115 @@
package migrate
// Перенос из панели в панель (вкладка «Перенос»): новая панель выдаёт одноразовый код
// awgmove://, старая шлёт по нему архив на её порт панели. Порт панели обычно — голый HTTP,
// а в архиве ключ подписи, поэтому и запрос, и ответ зашифрованы AES-256-GCM ключом из кода:
// без кода архив не прочесть и не подделать, а верный тег и есть доказательство, что код знают.
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
CodePrefix = "awgmove://"
keyLen = 32
// Разные AAD — ответ нельзя выдать за запрос и наоборот.
aadRequest = "awgmove-v1 request"
aadResponse = "awgmove-v1 response"
)
// Code — что старая панель узнаёт из кода: куда слать и чем шифровать.
type Code struct {
Host string
Port int
Key []byte
}
func NewKey() []byte {
k := make([]byte, keyLen)
rand.Read(k)
return k
}
func (c Code) String() string {
return CodePrefix + b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{
{Key: "v", Val: Version}, {Key: "h", Val: c.Host}, {Key: "p", Val: c.Port}, {Key: "k", Val: b64url.EncodeToString(c.Key)}}))
}
func ParseCode(s string) (Code, error) {
bad := errors.New("код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, CodePrefix) {
return Code{}, bad
}
raw, err := b64url.DecodeString(s[len(CodePrefix):])
if err != nil {
return Code{}, bad
}
v, err := jsjson.Decode(raw)
o, ok := v.(jsjson.Object)
if err != nil || !ok || o.Get2("v") != float64(Version) {
return Code{}, bad
}
host, _ := o.Get2("h").(string)
port, _ := o.Get2("p").(float64)
k, _ := o.Get2("k").(string)
key, err := b64url.DecodeString(k)
if host == "" || len(host) > 253 || strings.ContainsAny(host, "/?#@ ") || port <= 0 || port > 65535 ||
port != float64(int(port)) || err != nil || len(key) != keyLen {
return Code{}, bad
}
return Code{Host: host, Port: int(port), Key: key}, nil
}
// URL — куда слать архив.
func (c Code) URL() string {
h := c.Host
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
h = "[" + h + "]"
}
return "http://" + h + ":" + strconv.Itoa(c.Port) + "/migrate/v1/import"
}
func gcm(key []byte) (cipher.AEAD, error) {
blk, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(blk)
}
func seal(key []byte, aad string, plain []byte) []byte {
g, err := gcm(key)
if err != nil {
panic(err) // ключ всегда 32 байта
}
nonce := make([]byte, g.NonceSize())
rand.Read(nonce)
return g.Seal(nonce, nonce, plain, []byte(aad))
}
var ErrSeal = errors.New("не расшифровать: код не тот или данные повреждены")
func open(key []byte, aad string, box []byte) ([]byte, error) {
g, err := gcm(key)
if err != nil || len(box) < g.NonceSize() {
return nil, ErrSeal
}
plain, err := g.Open(nil, box[:g.NonceSize()], box[g.NonceSize():], []byte(aad))
if err != nil {
return nil, ErrSeal
}
return plain, nil
}
func SealRequest(key, plain []byte) []byte { return seal(key, aadRequest, plain) }
func OpenRequest(key, box []byte) ([]byte, error) { return open(key, aadRequest, box) }
func SealResponse(key, plain []byte) []byte { return seal(key, aadResponse, plain) }
func OpenResponse(key, box []byte) ([]byte, error) { return open(key, aadResponse, box) }
+159
View File
@@ -0,0 +1,159 @@
// Package relay — ретранслятор TCP на сервере, с которого переехала панель (`awg-control
// migrate`). В выданных ссылках sen://, у подключённых нод и у клиентов внешнего API остался
// старый адрес: relay слушает прежние порты подписки, хаба нод и панели и пересылает соединения
// на новую панель как есть — TLS идёт насквозь, ключи и сертификаты у панели прежние. Перед
// данными идёт заголовок PROXY v1 с адресом клиента: панель верит ему только от RELAY_FROM.
package relay
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
)
const dialTimeout = 10 * time.Second
// Route — одна пересылка: локальный порт → адрес новой панели.
type Route struct {
Listen int
Target string // host:port
}
// ParseRoutes — RELAY: «порт=хост:порт» через запятую.
func ParseRoutes(s string) ([]Route, error) {
var out []Route
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
l, t, ok := strings.Cut(part, "=")
port, err := strconv.Atoi(l)
if !ok || err != nil || port <= 0 || port > 65535 {
return nil, fmt.Errorf("relay: неверная пересылка %q (нужно порт=хост:порт)", part)
}
host, tp, err := net.SplitHostPort(t)
if n, perr := strconv.Atoi(tp); err != nil || host == "" || perr != nil || n <= 0 || n > 65535 {
return nil, fmt.Errorf("relay: неверный адрес %q", t)
}
out = append(out, Route{port, t})
}
if len(out) == 0 {
return nil, errors.New("relay: RELAY пуст — пересылать нечего")
}
return out, nil
}
// proxyHeader — строка PROXY v1 для соединения c.
func proxyHeader(c net.Conn) string {
src, ok1 := c.RemoteAddr().(*net.TCPAddr)
dst, ok2 := c.LocalAddr().(*net.TCPAddr)
if !ok1 || !ok2 {
return "PROXY UNKNOWN\r\n"
}
sip, dip := src.IP, dst.IP
fam := "TCP6"
if s4, d4 := sip.To4(), dip.To4(); s4 != nil && d4 != nil {
fam, sip, dip = "TCP4", s4, d4
} else if s4 != nil || d4 != nil {
// Смешанные семейства (v4-mapped на v6-сокете и т. п.) — оба в v6.
sip, dip = sip.To16(), dip.To16()
}
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, sip, dip, src.Port, dst.Port)
}
// pipe — копирование в обе стороны; конец одной стороны закрывает запись в другую.
func pipe(a, b net.Conn) {
var wg sync.WaitGroup
cp := func(dst, src net.Conn) {
defer wg.Done()
io.Copy(dst, src)
if tc, ok := dst.(*net.TCPConn); ok {
tc.CloseWrite()
} else {
dst.Close()
}
}
wg.Add(2)
go cp(a, b)
go cp(b, a)
wg.Wait()
a.Close()
b.Close()
}
func serve(ctx context.Context, ln net.Listener, r Route, errorf func(string, ...any)) {
var d net.Dialer
d.Timeout = dialTimeout
for {
c, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
errorf("relay: :%d: %v", r.Listen, err)
time.Sleep(time.Second)
continue
}
go func() {
up, err := d.DialContext(ctx, "tcp", r.Target)
if err != nil {
errorf("relay: :%d → %s: %v", r.Listen, r.Target, err)
c.Close()
return
}
if _, err := io.WriteString(up, proxyHeader(c)); err != nil {
c.Close()
up.Close()
return
}
pipe(c, up)
}()
}
}
// Run слушает все порты и пересылает до отмены ctx. Порт, который не открылся, — ошибка сразу:
// значит, его ещё держит прежняя панель.
func Run(ctx context.Context, routes []Route, logf, errorf func(string, ...any)) error {
var lns []net.Listener
for _, r := range routes {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(r.Listen))
if err != nil {
for _, l := range lns {
l.Close()
}
return fmt.Errorf("relay: порт %d: %w", r.Listen, err)
}
lns = append(lns, ln)
logf("relay: :%d → %s", r.Listen, r.Target)
}
for i, r := range routes {
go serve(ctx, lns[i], r, errorf)
}
<-ctx.Done()
for _, l := range lns {
l.Close()
}
return ctx.Err()
}
// Main — роль `awg-control relay`: пересылки из RELAY.
func Main(ctx context.Context) error {
routes, err := ParseRoutes(os.Getenv("RELAY"))
if err != nil {
return err
}
logf := func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
errorf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
return Run(ctx, routes, logf, errorf)
}
+102
View File
@@ -0,0 +1,102 @@
package relay
import (
"bufio"
"context"
"io"
"net"
"strconv"
"strings"
"testing"
"time"
)
func TestParseRoutes(t *testing.T) {
r, err := ParseRoutes(" 40123=203.0.113.7:40123, 8443=[2001:db8::1]:9000 ,")
if err != nil || len(r) != 2 || r[0] != (Route{40123, "203.0.113.7:40123"}) || r[1] != (Route{8443, "[2001:db8::1]:9000"}) {
t.Fatalf("ParseRoutes: %v %v", r, err)
}
for _, bad := range []string{"", ",", "40123", "0=h:1", "70000=h:1", "1=h", "1=:5", "1=h:0", "x=h:1"} {
if _, err := ParseRoutes(bad); err == nil {
t.Errorf("ParseRoutes(%q): ожидали ошибку", bad)
}
}
}
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
// Поток идёт насквозь в обе стороны, первой строкой у панели — PROXY с адресом клиента.
func TestRelayForwardsWithProxyHeader(t *testing.T) {
target, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer target.Close()
got := make(chan string, 2)
go func() {
c, err := target.Accept()
if err != nil {
return
}
defer c.Close()
r := bufio.NewReader(c)
hdr, _ := r.ReadString('\n')
got <- hdr
body, _ := io.ReadAll(r)
got <- string(body)
io.WriteString(c, "pong")
}()
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errs := make(chan error, 1)
go func() {
errs <- Run(ctx, []Route{{port, target.Addr().String()}}, func(string, ...any) {}, func(f string, a ...any) { t.Logf(f, a...) })
}()
var cl net.Conn
for range 50 {
if cl, err = net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))); err == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, "ping")
cl.(*net.TCPConn).CloseWrite()
hdr := <-got
src := cl.LocalAddr().(*net.TCPAddr)
if want := "PROXY TCP4 127.0.0.1 127.0.0.1 " + strconv.Itoa(src.Port) + " " + strconv.Itoa(port) + "\r\n"; hdr != want {
t.Errorf("заголовок %q, ожидали %q", hdr, want)
}
if body := <-got; body != "ping" {
t.Errorf("до панели дошло %q", body)
}
cl.SetReadDeadline(time.Now().Add(3 * time.Second))
if back, _ := io.ReadAll(cl); string(back) != "pong" {
t.Errorf("обратно пришло %q", back)
}
// Занятый порт — ошибка сразу: значит, его держит прежняя панель.
busy, _ := net.Listen("tcp", ":0")
defer busy.Close()
if err := Run(context.Background(), []Route{{busy.Addr().(*net.TCPAddr).Port, "127.0.0.1:1"}}, func(string, ...any) {}, t.Logf); err == nil ||
!strings.Contains(err.Error(), "порт") {
t.Errorf("занятый порт: %v", err)
}
cancel()
<-errs
}
+512
View File
@@ -0,0 +1,512 @@
package ui
// Аудит серверов (порт части awg-ui/sub.ts). Периодически core обходит все серверы и ищет
// «мёртвых» пиров: живы на сервере, хотя панель про них не знает. Сообщает и — если включена
// очистка или оператор нажал кнопку — снимает подтверждённых: мёртвых два прохода подряд.
// ghosts — есть в ядре (awg show), но неизвестны awg-ctrl;
// orphans — есть у awg-ctrl в области этой панели, но панели не нужны (sync их не снял);
// key — мастер-ключ пира, если он отозван или удалён.
// Пиры чужой области в отчёт не попадают — считаются в foreign.
import (
"regexp"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
type auditPeer struct {
PubKey string
Owner any // только у orphans
Key any // только у orphans
Online any
LastHandshake any
SeenBefore *bool
orphan bool
}
func (p *auditPeer) json() jsjson.Object {
o := jsjson.Object{{Key: "pub_key", Val: p.PubKey}}
if p.orphan {
o = append(o, jsjson.KV{Key: "owner", Val: p.Owner})
}
o = append(o, jsjson.KV{Key: "online", Val: p.Online}, jsjson.KV{Key: "lastHandshake", Val: p.LastHandshake})
if p.orphan {
o = append(o, jsjson.KV{Key: "key", Val: p.Key})
}
if p.SeenBefore != nil {
o = append(o, jsjson.KV{Key: "seen_before", Val: *p.SeenBefore})
}
return o
}
type auditEntry struct {
ServerID int
Status string // ok | offline | error
GhostsChecked bool // false — awg-ctrl старше /api/audit, «призраков» не видно
Ghosts []*auditPeer
Orphans []*auditPeer
Foreign int
Blocked *int // очистка отказалась снимать столько пиров разом (предохранитель)
}
func peersJSON(ps []*auditPeer) []any {
out := []any{}
for _, p := range ps {
out = append(out, p.json())
}
return out
}
func (e *auditEntry) json() jsjson.Object {
o := jsjson.Object{{Key: "server_id", Val: e.ServerID}, {Key: "status", Val: e.Status}, {Key: "ghosts_checked", Val: e.GhostsChecked},
{Key: "ghosts", Val: peersJSON(e.Ghosts)}, {Key: "orphans", Val: peersJSON(e.Orphans)}, {Key: "foreign", Val: e.Foreign}}
if e.Blocked != nil {
o = append(o, jsjson.KV{Key: "blocked", Val: *e.Blocked})
}
return o
}
type auditReport struct {
At int64
Servers []*auditEntry
}
func (r *auditReport) json() any {
if r == nil {
return nil
}
list := []any{}
for _, e := range r.Servers {
list = append(list, e.json())
}
return jsjson.Object{{Key: "at", Val: r.At}, {Key: "servers", Val: list}}
}
type cleanedPeer struct {
At int64
ServerID int
PubKey string
Kind string // ghost | orphan
Key any // у ghost нет (undefined)
HasKey bool
Auto bool
}
func (c cleanedPeer) json() jsjson.Object {
o := jsjson.Object{{Key: "at", Val: c.At}, {Key: "server_id", Val: c.ServerID}, {Key: "pub_key", Val: c.PubKey}, {Key: "kind", Val: c.Kind}}
if c.HasKey {
o = append(o, jsjson.KV{Key: "key", Val: c.Key})
}
return append(o, jsjson.KV{Key: "auto", Val: c.Auto})
}
// auditSettings — clean: снимать «мёртвых» сами; every_h — период; max — предохранитель.
type auditSettings struct {
Clean bool
EveryH int
Max int
}
func (s auditSettings) json() jsjson.Object {
return jsjson.Object{{Key: "clean", Val: s.Clean}, {Key: "every_h", Val: s.EveryH}, {Key: "max", Val: s.Max}}
}
// ── sub_meta: JSON-значения ─────────────────────────────────────────────────
func (s *Sub) metaGet(key string) any {
var raw string
if s.a.db.QueryRow("SELECT value FROM sub_meta WHERE key = ?", key).Scan(&raw) != nil {
return nil
}
v, err := jsjson.Decode([]byte(raw))
if err != nil {
return nil
}
return v
}
func (s *Sub) metaSet(key string, v any) {
s.a.db.Exec("INSERT INTO sub_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, string(jsjson.MustMarshal(v)))
}
func parsePeer(v any, orphan bool) *auditPeer {
o, _ := v.(jsjson.Object)
p := &auditPeer{PubKey: strOr(o.Get2("pub_key"), ""), Online: o.Get2("online"), LastHandshake: o.Get2("lastHandshake"), orphan: orphan}
if orphan {
p.Owner, p.Key = o.Get2("owner"), o.Get2("key")
}
if b, ok := o.Get2("seen_before").(bool); ok {
p.SeenBefore = &b
}
return p
}
func parseReport(v any) *auditReport {
o, ok := v.(jsjson.Object)
if !ok {
return nil
}
r := &auditReport{At: int64(numOr(o.Get2("at"), 0))}
for _, x := range arr(o.Get2("servers")) {
e := &auditEntry{ServerID: int(numOr(field(x, "server_id"), 0)), Status: strOr(field(x, "status"), "error"),
Foreign: int(numOr(field(x, "foreign"), 0))}
e.GhostsChecked, _ = field(x, "ghosts_checked").(bool)
for _, p := range arr(field(x, "ghosts")) {
e.Ghosts = append(e.Ghosts, parsePeer(p, false))
}
for _, p := range arr(field(x, "orphans")) {
e.Orphans = append(e.Orphans, parsePeer(p, true))
}
if b, ok := field(x, "blocked").(float64); ok {
n := int(b)
e.Blocked = &n
}
r.Servers = append(r.Servers, e)
}
return r
}
func (s *Sub) loadAudit() {
s.lastAudit = parseReport(s.metaGet("audit"))
s.auditSettings = auditSettings{Clean: false, EveryH: 1, Max: 10}
if o, ok := s.metaGet("audit_settings").(jsjson.Object); ok {
if b, ok := o.Get2("clean").(bool); ok {
s.auditSettings.Clean = b
}
if f, ok := o.Get2("every_h").(float64); ok {
s.auditSettings.EveryH = int(f)
}
if f, ok := o.Get2("max").(float64); ok {
s.auditSettings.Max = int(f)
}
}
for _, x := range arr(s.metaGet("audit_cleaned")) {
o, _ := x.(jsjson.Object)
k, hasKey := o.Get("key")
auto, _ := o.Get2("auto").(bool)
s.cleanedLog = append(s.cleanedLog, cleanedPeer{At: int64(numOr(o.Get2("at"), 0)), ServerID: int(numOr(o.Get2("server_id"), 0)),
PubKey: strOr(o.Get2("pub_key"), ""), Kind: strOr(o.Get2("kind"), ""), Key: k, HasKey: hasKey, Auto: auto})
}
}
func (s *Sub) cleanedJSON() []any {
out := []any{}
for _, c := range s.cleanedLog {
out = append(out, c.json())
}
return out
}
// ── Проход ──────────────────────────────────────────────────────────────────
var reOwnerMaster = regexp.MustCompile(`^[^/]+/m(\d+)/`)
func (s *Sub) auditNow(id int) (*auditEntry, error) {
e := &auditEntry{ServerID: id, Status: "ok", GhostsChecked: true}
if !s.a.nodes.Exists(id) || !s.a.nodes.IsOnline(id) {
e.Status, e.GhostsChecked = "offline", false
return e, nil
}
ctrl := s.a.nodes.CtrlFor(id)
// Сначала обычное сведение: отчёт — про то, что не снялось само.
if err := s.reconcileNow(id); err != nil {
return nil, err
}
a, err := ctrl("GET", "/api/audit", nil)
if err != nil {
return nil, err
}
p, err := ctrl("GET", "/api/peers", nil)
if err != nil {
return nil, err
}
fail := func() (*auditEntry, error) {
e.Status, e.GhostsChecked = "error", false
e.Ghosts, e.Orphans = nil, nil
return e, nil
}
if !ok2xx(p.Status) {
return fail()
}
if ok2xx(a.Status) {
for _, g := range arr(field(a.Data, "ghosts")) {
e.Ghosts = append(e.Ghosts, &auditPeer{PubKey: strOr(field(g, "pub_key"), ""), Online: field(g, "online"), LastHandshake: field(g, "lastHandshake")})
}
} else if a.Status == 404 {
e.GhostsChecked = false
} else {
return fail()
}
keep := map[string]bool{}
for _, k := range s.keepOf(id) {
keep[k] = true
}
prefix := s.panelID + "/"
for _, peer := range arr(field(p.Data, "peers")) {
owner, ok := field(peer, "owner").(string)
if !ok {
return nil, errTypeError // peer.owner.startsWith бросил бы TypeError
}
pub := strOr(field(peer, "pub_key"), "")
if !strings.HasPrefix(owner, prefix) {
if strings.Contains(owner, "/") {
e.Foreign++
}
continue
}
if keep[pub] {
continue
}
key := any("(нет в ui.db)")
if m := reOwnerMaster.FindStringSubmatch(owner); m != nil {
if mid, err := strconv.ParseInt(m[1], 10, 64); err == nil && mid <= 9007199254740991 {
if row := s.masterRowByID(s.a.db, mid); row != nil {
if row.RevokedAt.Valid {
key = row.Label + " (удалён)"
} else {
key = row.Label
}
}
}
}
e.Orphans = append(e.Orphans, &auditPeer{PubKey: pub, Owner: owner, Online: field(peer, "online"),
LastHandshake: field(peer, "lastHandshake"), Key: key, orphan: true})
}
return e, nil
}
type jsTypeError struct{}
func (jsTypeError) Error() string { return "TypeError" }
var errTypeError = jsTypeError{}
// audit — под той же блокировкой, что и сведение: пир, которого прямо сейчас добавляет проход, не «призрак».
func (s *Sub) audit(id int) *auditEntry {
l := s.lock(id)
l.Lock()
defer l.Unlock()
e, err := func() (e *auditEntry, err error) {
defer func() {
if recover() != nil {
e, err = nil, errTypeError
}
}()
return s.auditNow(id)
}()
if err != nil || e == nil {
return &auditEntry{ServerID: id, Status: "error"}
}
return e
}
// cleanEntry — снять «мёртвых» сервера: только подтверждённых (seen_before), не больше max за проход.
// «Призраки» — через awg-ctrl (он сам перепроверяет ключи), лишние — обычным DELETE.
func (s *Sub) cleanEntry(e *auditEntry, auto bool) error {
seen := func(ps []*auditPeer) []*auditPeer {
var out []*auditPeer
for _, p := range ps {
if p.SeenBefore != nil && *p.SeenBefore {
out = append(out, p)
}
}
return out
}
ghosts, orphans := seen(e.Ghosts), seen(e.Orphans)
if len(ghosts)+len(orphans) == 0 || e.Status != "ok" {
return nil
}
s.auditMu.Lock()
max := s.auditSettings.Max
s.auditMu.Unlock()
if auto && len(ghosts)+len(orphans) > max {
n := len(ghosts) + len(orphans)
e.Blocked = &n
warnf("sub: очистка сервера #%d пропущена: %d пиров больше предела %d", e.ServerID, n, max)
return nil
}
ctrl := s.a.nodes.CtrlFor(e.ServerID)
var done []cleanedPeer
log := func(p *auditPeer, kind string) {
done = append(done, cleanedPeer{At: now(), ServerID: e.ServerID, PubKey: p.PubKey, Kind: kind, Key: p.Key, HasKey: p.orphan, Auto: auto})
}
if len(ghosts) > 0 {
keys := []any{}
for _, g := range ghosts {
keys = append(keys, g.PubKey)
}
r, err := ctrl("POST", "/api/audit/clean", jsjson.Object{{Key: "pub_keys", Val: keys}})
if err != nil {
return err
}
if ok2xx(r.Status) {
removed := map[string]bool{}
for _, x := range arr(field(r.Data, "removed")) {
if k, ok := x.(string); ok {
removed[k] = true
}
}
for _, g := range ghosts {
if removed[g.PubKey] {
log(g, "ghost")
}
}
}
}
for _, p := range orphans {
r, err := ctrl("DELETE", "/api/peers/"+pubParam(p.PubKey), nil)
if err != nil {
return err
}
if ok2xx(r.Status) || r.Status == 404 {
log(p, "orphan")
} else if gone(r.Status) {
break
}
}
if len(done) == 0 {
return nil
}
cleaned := map[string]bool{}
var short []string
for _, d := range done {
cleaned[d.PubKey] = true
k := d.PubKey
if len(k) > 8 {
k = k[:8]
}
short = append(short, k)
}
drop := func(ps []*auditPeer) []*auditPeer {
var out []*auditPeer
for _, p := range ps {
if !cleaned[p.PubKey] {
out = append(out, p)
}
}
return out
}
e.Ghosts, e.Orphans = drop(e.Ghosts), drop(e.Orphans)
s.auditMu.Lock()
s.cleanedLog = append(done, s.cleanedLog...)
if len(s.cleanedLog) > 100 {
s.cleanedLog = s.cleanedLog[:100]
}
s.metaSet("audit_cleaned", s.cleanedJSON())
s.auditMu.Unlock()
what := "очистка"
if auto {
what = "автоочистка"
}
warnf("sub: %s сервера #%d: снято %d (%s)", what, e.ServerID, len(done), strings.Join(short, ", "))
return nil
}
// cleanLocked — очистка под блокировкой сервера, как и сам аудит.
func (s *Sub) cleanLocked(e *auditEntry, auto bool) {
l := s.lock(e.ServerID)
l.Lock()
defer l.Unlock()
if err := s.cleanEntry(e, auto); err != nil && auto {
warnf("sub: очистка сервера #%d не удалась: %v", e.ServerID, err)
}
}
// auditAll — проход по всем серверам; одновременно идёт не больше одного (повторный вызов ждёт
// текущий). auto — плановый: очищает сам, если включено. Ручной только смотрит.
func (s *Sub) auditAll(auto bool) {
s.auditMu.Lock()
if ch := s.auditing; ch != nil {
s.auditMu.Unlock()
<-ch
return
}
ch := make(chan struct{})
s.auditing = ch
prev := s.lastAudit
s.auditMu.Unlock()
defer func() {
s.auditMu.Lock()
s.auditing = nil
s.auditMu.Unlock()
close(ch)
}()
ids := s.a.nodes.IDs()
report := &auditReport{At: now(), Servers: make([]*auditEntry, len(ids))}
done := make(chan struct{}, len(ids))
for i, id := range ids {
go func() { report.Servers[i] = s.audit(id); done <- struct{}{} }()
}
for range ids {
<-done
}
s.auditMu.Lock()
clean := s.auditSettings.Clean
s.auditMu.Unlock()
for _, e := range report.Servers {
was := map[string]bool{}
if prev != nil {
for _, x := range prev.Servers {
if x.ServerID == e.ServerID {
for _, p := range append(append([]*auditPeer{}, x.Ghosts...), x.Orphans...) {
was[p.PubKey] = true
}
break
}
}
}
for _, p := range append(append([]*auditPeer{}, e.Ghosts...), e.Orphans...) {
b := was[p.PubKey]
p.SeenBefore = &b
}
if auto && clean {
s.cleanLocked(e, true)
}
}
s.auditMu.Lock()
s.lastAudit = report
s.metaSet("audit", report.json())
s.auditMu.Unlock()
for _, e := range report.Servers {
if e.Status == "error" {
warnf("sub: аудит сервера #%d не удался", e.ServerID)
}
if n := len(e.Ghosts) + len(e.Orphans); n > 0 {
online := 0
for _, p := range append(append([]*auditPeer{}, e.Ghosts...), e.Orphans...) {
if truthy(p.Online) {
online++
}
}
warnf("sub: аудит сервера #%d: мёртвых пиров %d (в ядре без записи: %d, лишних у awg-ctrl: %d, онлайн: %d)",
e.ServerID, n, len(e.Ghosts), len(e.Orphans), online)
}
}
}
// cleanNow — ручная очистка подтверждённых «мёртвых» по последнему отчёту.
func (s *Sub) cleanNow() {
s.auditMu.Lock()
rep := s.lastAudit
s.auditMu.Unlock()
if rep == nil {
return
}
for _, e := range rep.Servers {
s.cleanLocked(e, false)
}
s.auditMu.Lock()
s.metaSet("audit", rep.json())
s.auditMu.Unlock()
}
func (s *Sub) auditState() jsjson.Object {
s.auditMu.Lock()
defer s.auditMu.Unlock()
return jsjson.Object{{Key: "enabled", Val: s.auditOn}, {Key: "settings", Val: s.auditSettings.json()},
{Key: "report", Val: s.lastAudit.json()}, {Key: "cleaned", Val: s.cleanedJSON()}}
}
+104
View File
@@ -0,0 +1,104 @@
package ui
import (
"errors"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
// «Серверы для всех»: add — у каждого живого ключа, remove — ни у кого, остальное не трогается;
// пиры снятых серверов — в надгробия, новые ключи получают набор по умолчанию.
func TestBulkServers(t *testing.T) {
p := newMovePanel(t, "off", "40500")
s, db := p.s, p.a.db
if err := p.a.initAPIKeys(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'A', 'x', 1), (2, 'B', 'x', 1), (3, 'C', 'x', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k1', x'01', 1), (2, 'u2', 'k2', x'02', 1), (3, 'u3', 'k3', x'03', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (4, 'u4', 'k4', x'04', 1, 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 1), (2, 1), (2, 2), (3, 2), (4, 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 2, 'd', 'W', 'A', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 1, 'P1', '10.0.0.2', 'K'), (5, 2, 'P2', '10.0.1.2', 'K')",
"INSERT INTO api_keys (id, label, key_hash, prefix, created_at) VALUES (1, 'Бот', 'h1', 'p1', 1), (2, 'Всё', 'h2', 'p2', 1)",
"INSERT INTO api_key_servers (key_id, server_id) VALUES (1, 1), (1, 2), (2, 1), (2, 2), (2, 3)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(q, err)
}
}
servers := func() string {
var out []string
for _, id := range []int64{1, 2, 3, 4} {
out = append(out, string(jsjson.MustMarshal(intsJSON(s.serverIDsOf(db, id)))))
}
return strings.Join(out, " ")
}
body := func(kv ...any) jsjson.Object {
o := jsjson.Object{}
for i := 0; i < len(kv); i += 2 {
o = append(o, jsjson.KV{Key: kv[i].(string), Val: kv[i+1]})
}
return o
}
f := func(ids ...float64) []any {
out := []any{}
for _, x := range ids {
out = append(out, x)
}
return out
}
status := func(err error) int {
var ae *apiError
if errors.As(err, &ae) {
return ae.status
}
return 0
}
out, err := s.bulkServers(body("add", f(3), "remove", f(2), "dry_run", true))
if err != nil {
t.Fatal(err)
}
if got := string(jsjson.MustMarshal(out)); got != `{"changed":3,"total":3,"hidden":[{"id":1,"label":"Бот","keys":3}]}` {
t.Errorf("предпросмотр: %s", got)
}
if got := servers(); got != "[1] [1,2] [2] [1]" {
t.Errorf("предпросмотр изменил ключи: %s", got)
}
if _, err := s.bulkServers(body("remove", f(1, 2))); status(err) != 409 {
t.Errorf("ключи без серверов: %v", err)
}
if _, err := s.bulkServers(body("add", f(2), "remove", f(2))); status(err) != 400 {
t.Errorf("и у всех, и ни у кого: %v", err)
}
if _, err := s.bulkServers(body("add", f(9))); status(err) != 400 {
t.Errorf("несуществующий сервер: %v", err)
}
if _, err := s.bulkServers(body("add", f(3), "remove", f(2), "defaults", f(3))); err != nil {
t.Fatal(err)
}
if got := servers(); got != "[1,3] [1,3] [3] [1]" {
t.Errorf("после применения: %s (удаляемый ключ трогать нельзя)", got)
}
var n int
db.QueryRow("SELECT COUNT(*) FROM peer_tombstones WHERE server_id = 2 AND pub_key = 'P2'").Scan(&n)
if n != 1 {
t.Error("пир снятого сервера не в надгробиях")
}
db.QueryRow("SELECT COUNT(*) FROM device_peers WHERE device_id = 5 AND server_id = 3").Scan(&n)
if n != 1 {
t.Error("устройство не получило заявку на новом сервере")
}
m, err := s.createMaster(body("label", "Новый"), nil)
if err != nil || string(jsjson.MustMarshal(m.Get2("servers"))) != "[3]" {
t.Errorf("новый ключ: %v %v", m, err)
}
if _, err := s.bulkServers(body("defaults", nil)); err != nil || s.defaultServers() != nil {
t.Errorf("сброс набора по умолчанию: %v %v", s.defaultServers(), err)
}
}
+122
View File
@@ -0,0 +1,122 @@
package ui
import (
"io"
"log"
"math"
"net/http"
"strconv"
"strings"
"unicode/utf16"
)
// jsToNumber — Number(s) для строки: пробелы по краям, пусто → 0, 0x/0o/0b, Infinity;
// всё прочее нечисловое — NaN.
func jsToNumber(s string) float64 {
t := strings.TrimSpace(s)
if t == "" {
return 0
}
switch t {
case "Infinity", "+Infinity":
return math.Inf(1)
case "-Infinity":
return math.Inf(-1)
}
if len(t) > 2 && t[0] == '0' {
base := 0
switch t[1] {
case 'x', 'X':
base = 16
case 'o', 'O':
base = 8
case 'b', 'B':
base = 2
}
if base != 0 {
n, err := strconv.ParseUint(t[2:], base, 64)
if err != nil {
return math.NaN()
}
return float64(n)
}
}
for _, r := range t {
if !(r >= '0' && r <= '9' || r == '.' || r == 'e' || r == 'E' || r == '+' || r == '-') {
return math.NaN()
}
}
f, err := strconv.ParseFloat(t, 64)
if err != nil {
return math.NaN()
}
return f
}
// jsToNumberAny — Number(v) для значения из JSON.
func jsToNumberAny(v any) float64 {
switch x := v.(type) {
case nil:
return 0
case bool:
if x {
return 1
}
return 0
case float64:
return x
case string:
return jsToNumber(x)
}
return math.NaN()
}
// encodeURIComponent — как в JS: не трогает A-Z a-z 0-9 - _ . ! ~ * ' ( ).
func encodeURIComponent(s string) string {
const hexd = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || strings.IndexByte("-_.!~*'()", c) >= 0 {
b.WriteByte(c)
} else {
b.WriteByte('%')
b.WriteByte(hexd[c>>4])
b.WriteByte(hexd[c&15])
}
}
return b.String()
}
func readAll(resp *http.Response) ([]byte, error) { return io.ReadAll(resp.Body) }
// utf16Len — длина строки в JS (кодовые единицы UTF-16).
func utf16Len(s string) int {
n := 0
for _, r := range s {
if r >= 0x10000 {
n += 2
} else {
n++
}
}
return n
}
// lenOK — `{min,max}` в JS-регулярке над строкой целиком: длина в единицах UTF-16.
func lenOK(s string, min, max int) bool {
n := utf16Len(s)
return n >= min && n <= max
}
// sliceUTF16 — s.slice(0, n) в JS. Разрезанная суррогатная пара при записи в SQLite
// превращается в U+FFFD — так же делаем и здесь.
func sliceUTF16(s string, n int) string {
units := utf16.Encode([]rune(s))
if len(units) <= n {
return s
}
return string(utf16.Decode(units[:n]))
}
func quietLog() *log.Logger { return log.New(io.Discard, "", 0) }
+484
View File
@@ -0,0 +1,484 @@
package ui
// Prometheus: GET /metrics на ядре (порт awg-ui/metrics.ts). Метрики ядра и всех нод
// собираются здесь же — у нод через уже открытый WSS. Доступ — отдельный токен «только чтение
// метрик»; он не открывает ни /api/v1, ни панель.
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"math"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
scrapeDeadline = 5 * time.Second
touchEveryS = 60 // last_used пишем не чаще раза в минуту, а не на каждый скрейп
)
func hashToken(t string) []byte {
s := sha256.Sum256([]byte(t))
return s[:]
}
type Metrics struct {
a *App
perPeer bool
localFile string
mu sync.Mutex
localMod time.Time
localSeen bool
localHash []byte
}
func newMetrics(a *App) (*Metrics, error) {
m := &Metrics{
a: a,
// Пер-пировые серии (peer = внутренний IP, name = имя пользователя) выключены: это и
// приватность, и кардинальность. Включается явно.
perPeer: strings.ToLower(envOr("METRICS_PEERS", "off")) == "on",
localFile: envOr("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"),
}
_, err := a.db.Exec(`
CREATE TABLE IF NOT EXISTS metrics_token (
id INTEGER PRIMARY KEY CHECK (id = 1),
token_hash BLOB NOT NULL,
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER
)
`)
return m, err
}
var reHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`)
// localTokenHash — токен встроенного Prometheus (`awg-ctrl monitor install`): здесь его хэш.
// Файл перечитывается по mtime: установка не требует перезапуска панели.
func (m *Metrics) localTokenHash() []byte {
m.mu.Lock()
defer m.mu.Unlock()
st, err := os.Stat(m.localFile)
if err != nil {
m.localSeen, m.localHash = false, nil
return nil
}
if !m.localSeen || !st.ModTime().Equal(m.localMod) {
raw, err := os.ReadFile(m.localFile)
if err != nil {
m.localSeen, m.localHash = false, nil
return nil
}
m.localSeen, m.localMod, m.localHash = true, st.ModTime(), nil
if h := strings.TrimSpace(string(raw)); reHex64.MatchString(h) {
m.localHash, _ = hex.DecodeString(h)
}
}
return m.localHash
}
type tokenRow struct {
hash []byte
prefix string
createdAt int64
lastUsed sql.NullInt64
}
func (m *Metrics) token() *tokenRow {
t := &tokenRow{}
if m.a.db.QueryRow("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&t.hash, &t.prefix, &t.createdAt, &t.lastUsed) != nil {
return nil
}
return t
}
func (m *Metrics) view() jsjson.Object {
if t := m.token(); t != nil {
return jsjson.Object{{Key: "exists", Val: true}, {Key: "prefix", Val: t.prefix}, {Key: "created_at", Val: t.createdAt},
{Key: "last_used", Val: nullInt(t.lastUsed)}, {Key: "per_peer", Val: m.perPeer}}
}
return jsjson.Object{{Key: "exists", Val: false}, {Key: "per_peer", Val: m.perPeer}}
}
// router — управление токеном (роуты панели, за JWT). Выпуск и перевыпуск — одно и то же:
// старый токен перестаёт работать. Значение отдаётся один раз.
func (m *Metrics) router() *Router {
rt := newRouter()
rt.Get("/", func(c *Ctx) bool { return c.JSON(200, m.view()) })
rt.Post("/", func(c *Ctx) bool {
b := make([]byte, 24)
rand.Read(b)
tok := "awgm_" + b64url.EncodeToString(b)
m.a.db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)",
hashToken(tok), tok[:13]+"…", now())
return c.JSON(201, append(m.view(), jsjson.KV{Key: "token", Val: tok}))
})
rt.Delete("/", func(c *Ctx) bool {
m.a.db.Exec("DELETE FROM metrics_token WHERE id = 1")
return c.JSON(200, m.view())
})
return rt
}
func writeText(c *Ctx, status int, ctype, body string) bool {
c.W.Header().Set("Content-Type", ctype)
c.W.WriteHeader(status)
c.W.Write([]byte(body))
return false
}
// requireToken — нет токена или он неверный — одинаковый 401: не подсказываем, выключены
// метрики или токен не тот.
func (m *Metrics) requireToken(c *Ctx) bool {
given := bearer(c.R)
row := m.token()
var gh []byte
if strings.HasPrefix(given, "awgm_") {
gh = hashToken(given)
}
panelOK := row != nil && gh != nil && len(row.hash) == len(gh) && subtle.ConstantTimeCompare(gh, row.hash) == 1
local := m.localTokenHash()
localOK := gh != nil && local != nil && subtle.ConstantTimeCompare(gh, local) == 1
if !panelOK && !localOK {
c.W.Header().Set("WWW-Authenticate", "Bearer")
return writeText(c, 401, "text/plain; charset=utf-8", "unauthorized\n")
}
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
if t := now(); panelOK && (!row.lastUsed.Valid || row.lastUsed.Int64 == 0 || t-row.lastUsed.Int64 >= touchEveryS) {
m.a.db.Exec("UPDATE metrics_token SET last_used = ? WHERE id = 1", t)
}
return true
}
// ── Экспозиция ──────────────────────────────────────────────────────────────
type label struct {
k string
v any
}
type sample struct {
labels []label
v any // число; не число и не конечное — 0
}
type exposition struct {
b bytes.Buffer
seen map[string]bool
}
var (
escLabel = strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
escHelp = strings.NewReplacer(`\`, `\\`, "\n", `\n`)
)
// labelString — String(v) для значения метки.
func labelString(v any) string {
switch x := v.(type) {
case string:
return x
case int:
return jsjson.Number(float64(x))
case int64:
return jsjson.Number(float64(x))
case float64:
return jsjson.Number(x)
case bool:
if x {
return "true"
}
return "false"
case nil:
return "undefined"
}
return "[object Object]"
}
func (e *exposition) add(name, typ, help string, samples []sample) {
if len(samples) == 0 {
return
}
if !e.seen[name] {
e.seen[name] = true
e.b.WriteString("# HELP " + name + " " + escHelp.Replace(help) + "\n# TYPE " + name + " " + typ + "\n")
}
for _, s := range samples {
e.b.WriteString(name)
if len(s.labels) > 0 {
parts := make([]string, len(s.labels))
for i, l := range s.labels {
parts[i] = l.k + `="` + escLabel.Replace(labelString(l.v)) + `"`
}
e.b.WriteString("{" + strings.Join(parts, ",") + "}")
}
v := 0.0
switch x := s.v.(type) {
case float64:
v = x
case int:
v = float64(x)
case int64:
v = float64(x)
}
if math.IsNaN(v) || math.IsInf(v, 0) {
v = 0
}
e.b.WriteString(" " + jsjson.Number(v) + "\n")
}
}
func b2f(b bool) float64 {
if b {
return 1
}
return 0
}
// scrapeServer — один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
func (m *Metrics) scrapeServer(s serverInfo) any {
if s.Pending || !m.a.nodes.IsOnline(s.ID) {
return nil
}
ch := make(chan any, 1)
go func() {
r, err := m.a.nodes.CtrlFor(s.ID)("GET", "/api/metrics", nil)
if err != nil || r.Status != 200 {
ch <- nil
return
}
ch <- r.Data
}()
select {
case d := <-ch:
return d
case <-time.After(scrapeDeadline):
return nil
}
}
func (m *Metrics) render() string {
t0 := time.Now()
list := m.a.nodes.List()
data := make([]any, len(list))
var wg sync.WaitGroup
for i, s := range list {
wg.Add(1)
go func() { defer wg.Done(); data[i] = m.scrapeServer(s) }()
}
wg.Wait()
out := &exposition{seen: map[string]bool{}}
L := func(s serverInfo, extra ...label) []label {
return append([]label{{"server", s.Name}, {"server_id", s.ID}}, extra...)
}
// Серии только тех серверов, чьи метрики получены.
per := func(fn func(s serverInfo, d any) []sample) []sample {
var res []sample
for i, s := range list {
if truthy(data[i]) {
res = append(res, fn(s, data[i])...)
}
}
return res
}
one := func(fn func(d any) any) func(serverInfo, any) []sample {
return func(s serverInfo, d any) []sample { return []sample{{L(s), fn(d)}} }
}
or0 := func(v any) any {
if v == nil {
return 0.0
}
return v
}
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", []sample{{[]label{{"version", m.a.version}}, 1.0}})
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", []sample{{nil, float64(len(list))}})
var online, scrapeOK, info []sample
for i, s := range list {
online = append(online, sample{L(s), b2f(s.Online)})
scrapeOK = append(scrapeOK, sample{L(s), b2f(truthy(data[i]))})
if !s.Pending {
gen, module, tools := any(""), any(""), any("")
if s.Health != nil {
gen, module, tools = s.Health.Gen, s.Health.Module, s.Health.Tools
}
ver := s.Version
if ver == nil {
ver = ""
}
info = append(info, sample{L(s, label{"version", ver}, label{"gen", gen}, label{"module", module}, label{"tools", tools}), 1.0})
}
}
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", online)
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", scrapeOK)
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.", info)
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per(one(func(d any) any { return b2f(truthy(field(d, "up"))) })))
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per(one(func(d any) any { return or0(field(d, "peers", "total")) })))
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per(one(func(d any) any { return or0(field(d, "peers", "online")) })))
// Сумма по пирам — gauge, а не counter: при удалении пира сумма уменьшается.
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "rx_bytes")) })))
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "tx_bytes")) })))
for _, x := range [][3]string{
{"rx_bytes", "receive_bytes", "байт"}, {"tx_bytes", "transmit_bytes", "байт"},
{"rx_packets", "receive_packets", "пакетов"}, {"tx_packets", "transmit_packets", "пакетов"},
{"rx_dropped", "receive_dropped", "потерянных пакетов"}, {"tx_dropped", "transmit_dropped", "потерянных пакетов"},
{"rx_errors", "receive_errors", "ошибок"}, {"tx_errors", "transmit_errors", "ошибок"},
} {
out.add("awg_link_"+x[1]+"_total", "counter", "Интерфейс awg1: "+x[2]+" ("+x[0]+").", per(one(func(d any) any { return or0(field(d, "link", x[0])) })))
}
// Нагрузка хоста. CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
per(func(s serverInfo, d any) []sample {
cpu, _ := field(d, "host", "cpu").(jsjson.Object)
var res []sample
for _, kv := range cpu {
res = append(res, sample{L(s, label{"mode", kv.Key}), jsToNumberAny(kv.Val)})
}
return res
}))
hostOne := func(fn func(h any) any) func(serverInfo, any) []sample {
return func(s serverInfo, d any) []sample {
h := field(d, "host")
if !truthy(h) {
return nil
}
return []sample{{L(s), fn(h)}}
}
}
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per(hostOne(func(h any) any { return or0(field(h, "cores")) })))
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "total")) })))
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "available")) })))
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).", per(func(s serverInfo, d any) []sample {
h := field(d, "host")
if !truthy(h) {
return nil
}
load := arr(field(h, "load"))
var res []sample
for i, p := range []string{"1m", "5m", "15m"} {
var v any = 0.0
if i < len(load) && load[i] != nil {
v = load[i]
}
res = append(res, sample{L(s, label{"period", p}), v})
}
return res
}))
// Деление канала (CAKE)
sh := func(fn func(x any) any) []sample {
return per(func(s serverInfo, d any) []sample {
x := field(d, "shaping")
if !truthy(x) {
return nil
}
return []sample{{L(s), fn(x)}}
})
}
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "settings", "enabled"))) }))
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "active"))) }))
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "congested"))) }))
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(func(x any) any {
if h, ok := field(x, "heavy").([]any); ok {
return float64(len(h))
}
return 0.0
}))
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(func(x any) any { return or0(field(x, "devices")) }))
// dirs — значение nil (undefined) серию не даёт.
dirs := func(fn func(x any, dir string) any) []sample {
return per(func(s serverInfo, d any) []sample {
x := field(d, "shaping")
if !truthy(x) {
return nil
}
var res []sample
for _, dir := range []string{"down", "up"} {
if v := fn(x, dir); v != nil {
res = append(res, sample{L(s, label{"direction", dir}), v})
}
}
return res
})
}
mbit := func(v any) any {
if v == nil {
return math.NaN() // undefined * 1e6
}
return jsToNumberAny(v) * 1e6
}
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs(func(x any, dir string) any {
if dir == "down" {
return mbit(field(x, "settings", "downMbit"))
}
return mbit(field(x, "settings", "upMbit"))
}))
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs(func(x any, dir string) any {
if dir == "down" {
return mbit(field(x, "rateDown"))
}
return mbit(field(x, "rateUp"))
}))
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "drops") }))
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "backlog") }))
// Блокировка BitTorrent
tb := func(fn func(x any) any) []sample {
return per(func(s serverInfo, d any) []sample {
x := field(d, "torrent")
if !truthy(x) {
return nil
}
return []sample{{L(s), fn(x)}}
})
}
out.add("awg_torrent_block_enabled", "gauge", "Блокировка BitTorrent включена в настройках (1/0).", tb(func(x any) any { return b2f(truthy(field(x, "settings", "enabled"))) }))
out.add("awg_torrent_block_active", "gauge", "nft-таблица блокировки реально стоит (1/0).", tb(func(x any) any { return b2f(truthy(field(x, "active"))) }))
out.add("awg_torrent_blocked_total", "counter", "Заблокированных попыток торрент-соединений с последнего применения правил.", tb(func(x any) any { return or0(field(x, "hits")) }))
out.add("awg_torrent_devices", "gauge", "Устройств, пытавшихся качать торренты за последние 10 минут.", tb(func(x any) any { return float64(len(arr(field(x, "seen")))) }))
if m.perPeer {
peers := func(fn func(p any) any) []sample {
return per(func(s serverInfo, d any) []sample {
var res []sample
for _, p := range arr(field(d, "list")) {
name := field(p, "name")
if name == nil {
name = ""
}
res = append(res, sample{L(s, label{"peer", field(p, "ip")}, label{"name", name}, label{"kind", field(p, "kind")}), fn(p)})
}
return res
})
}
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "online"))) }))
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(func(p any) any { return b2f(field(p, "priority") == "background") }))
out.add("awg_peer_torrent", "gauge", "Пир пытался открыть торрент-соединение за последние 10 минут (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "torrent"))) }))
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(func(p any) any { return field(p, "rx") }))
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(func(p any) any { return field(p, "tx") }))
}
var masters, devices int64
m.a.db.QueryRow("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").Scan(&masters)
m.a.db.QueryRow("SELECT COUNT(*) AS n FROM devices").Scan(&devices)
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", []sample{{nil, masters}})
out.add("awg_devices", "gauge", "Устройств sen://.", []sample{{nil, devices}})
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", []sample{{nil, time.Since(t0).Seconds()}})
return out.b.String()
}
func (m *Metrics) handler(c *Ctx) bool {
return writeText(c, 200, "text/plain; version=0.0.4; charset=utf-8", m.render())
}
+454
View File
@@ -0,0 +1,454 @@
package ui
// Вкладка «Перенос»: панель переезжает с сервера «панель + VPN» на отдельную панель без VPN
// прямо из интерфейса, без архива в руках. Те же шаги, что `awg-control migrate` (общий код —
// internal/migrate), только архив идёт из панели в панель:
//
// новая: POST /ui/migrate/code → одноразовый код awgmove:// (адрес, порт панели, ключ)
// старая: POST /ui/migrate/export → замораживает /sub/v1, шлёт зашифрованный архив на
// POST /migrate/v1/import новой панели
// новая: раскладывает архив, заводит ноду #0, отвечает join-строкой и перезапускается
// старая: пишет PANEL_MOVED и запускает `awg-control join` — становится нодой #0 с ретранслятором
//
// Перезапуск и join делает CLI в отдельном процессе: панель останавливает сама себя.
import (
"bytes"
"database/sql"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
const (
moveCodeTTL = time.Hour
moveMaxFails = 5
moveMaxBundle = 1 << 30
moveTimeout = 10 * time.Minute
)
// Состояние переноса на этой панели (в памяти: перезапуск его сбрасывает, и так и надо).
const (
moveIdle = ""
moveSending = "sending" // старая: архив в пути, /sub/v1 заморожен
moveMoved = "moved" // старая: новая приняла, сейчас будет join
moveUncertain = "uncertain" // старая: архив ушёл, а ответа нет — новая могла его принять
moveImported = "imported" // новая: архив принят, сейчас перезапуск
)
type Migration struct {
a *App
envFile string // cli.env: сюда пишутся порты, PANEL_MOVED, RELAY_FROM
run func(args ...string) // `awg-control …` отдельным процессом; в тестах — заглушка
mu sync.Mutex
key []byte // новая: ключ действующего кода
host string
expires time.Time
fails int
state string
errText string
target string // старая: адрес новой панели
}
func newMigration(a *App) *Migration {
m := &Migration{a: a, envFile: os.Getenv("CLI_ENV_FILE")}
m.run = m.spawn
return m
}
func (a *App) filePath(env, name string) string {
if v := os.Getenv(env); v != "" {
return v
}
return filepath.Join(a.baseDir, name)
}
// paths — где awg-ui держит файлы из архива (те же правила, что при их загрузке).
func (m *Migration) paths() migrate.Paths {
return migrate.PathsFrom(func(env string) string {
for _, f := range migrate.Files {
if f.Env == env {
return m.a.filePath(env, f.Name)
}
}
return ""
})
}
// canExport / canAccept — "" или причина, почему нельзя.
func (m *Migration) canExport() string {
switch {
case m.envFile == "":
return "Панель запущена не через awg-control — перенос недоступен"
case os.Getenv("CORE_HOST") != "":
return "Этот сервер уже нода другой панели"
case !m.a.nodes.local:
return "У этой панели нет своего VPN: переносить её как сервер-ноду нечего"
}
return ""
}
func (m *Migration) canAccept() string {
switch {
case m.envFile == "":
return "Панель запущена не через awg-control — перенос недоступен"
case m.a.nodes.local:
return "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
case !m.a.nodes.hubReady():
return "Не настроен приём нод (NODE_PORT): старый сервер подключится сюда нодой"
case migrate.Count(m.a.db) > 0:
return "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую"
}
return ""
}
func (m *Migration) router() *Router {
rt := newRouter()
rt.Get("/", m.status)
rt.Post("/code", m.newCode)
rt.Delete("/code", func(c *Ctx) bool {
m.mu.Lock()
m.key = nil
m.mu.Unlock()
return m.status(c)
})
rt.Post("/export", m.export)
rt.Post("/cancel", m.cancel)
rt.Delete("/retire", func(c *Ctx) bool {
if err := envfile.Set(m.envFile, nil, "MOVED_IN_AT"); err != nil {
return c.Error(500, err.Error())
}
os.Unsetenv("MOVED_IN_AT")
return m.status(c)
})
return rt
}
func (m *Migration) status(c *Ctx) bool {
m.mu.Lock()
state, errText, target := m.state, m.errText, m.target
var code any
if m.key != nil && time.Now().Before(m.expires) {
code = jsjson.Object{{Key: "code", Val: migrate.Code{Host: m.host, Port: m.a.port, Key: m.key}.String()},
{Key: "expires_in", Val: int(time.Until(m.expires).Seconds())}}
}
m.mu.Unlock()
o := jsjson.Object{{Key: "state", Val: state}, {Key: "error", Val: orNil(errText)}, {Key: "target", Val: orNil(target)}, {Key: "code", Val: code}}
// После приёма база закрыта до перезапуска — что можно, уже не считаем.
if state != moveImported {
o = append(o, jsjson.KV{Key: "export", Val: orNil(m.canExport())}, jsjson.KV{Key: "accept", Val: orNil(m.canAccept())},
jsjson.KV{Key: "retire", Val: m.retire()})
}
return c.JSON(200, o)
}
// ── Новая панель ────────────────────────────────────────────────────────────
// retire — можно ли выключить старый сервер (ноду #0 с ретранслятором): кто из клиентов и нод
// уже знает новый адрес панели. Клиент узнаёт его из endpoints любого конфига после переноса,
// нода — из ready (core_ok). Ссылки sen:// без устройств ещё никем не открыты: в них старый
// адрес, их выдают заново. nil — панель ничего не принимала (или отметку убрали).
func (m *Migration) retire() any {
since, _ := strconv.ParseInt(os.Getenv("MOVED_IN_AT"), 10, 64)
if since == 0 || m.a.nodes.local {
return nil
}
home := m.a.nodes.home()
var total, fresh, unused int
m.a.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(d.last_seen >= ?), 0) FROM devices d
JOIN master_keys k ON k.id = d.master_id WHERE k.revoked_at IS NULL`, since).Scan(&total, &fresh)
m.a.db.QueryRow(`SELECT COUNT(*) FROM master_keys k WHERE k.revoked_at IS NULL
AND NOT EXISTS (SELECT 1 FROM devices d WHERE d.master_id = k.id)`).Scan(&unused)
nodes := []any{}
if rows, err := m.a.db.Query("SELECT id, name, core_ack FROM nodes WHERE id != 0 AND pub IS NOT NULL ORDER BY id"); err == nil {
for rows.Next() {
var id int64
var name string
var ack sql.NullString
if rows.Scan(&id, &name, &ack) == nil {
nodes = append(nodes, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name},
{Key: "online", Val: m.a.nodes.IsOnline(int(id))}, {Key: "ok", Val: home != "" && ack.String == home}})
}
}
rows.Close()
}
return jsjson.Object{{Key: "since", Val: since}, {Key: "home", Val: orNil(home)},
{Key: "old", Val: jsjson.Object{{Key: "exists", Val: m.a.nodes.Exists(0)}, {Key: "online", Val: m.a.nodes.IsOnline(0)}}},
{Key: "devices", Val: jsjson.Object{{Key: "total", Val: total}, {Key: "fresh", Val: fresh}}},
{Key: "unused_links", Val: unused}, {Key: "nodes", Val: nodes}}
}
func (m *Migration) newCode(c *Ctx) bool {
if why := m.canAccept(); why != "" {
return c.Error(409, why)
}
host, _ := c.Obj().Get2("host").(string)
if h := strings.TrimSpace(os.Getenv("SUB_HOST")); h != "" {
host = h
}
if !reHost.MatchString(host) {
return c.Error(400, "Не удалось определить адрес панели — задайте SUB_HOST")
}
m.mu.Lock()
m.key, m.host, m.expires, m.fails = migrate.NewKey(), host, time.Now().Add(moveCodeTTL), 0
m.mu.Unlock()
logf("migrate: выдан код переноса (действует час)")
return m.status(c)
}
// importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM).
// Ответ с ошибкой — открытым текстом и только пока ничего не тронуто.
func (m *Migration) importHandler(c *Ctx) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.key == nil || time.Now().After(m.expires) {
return c.Error(404, "Новая панель не ждёт переноса: выдайте код заново")
}
box, err := io.ReadAll(http.MaxBytesReader(c.W, c.R.Body, moveMaxBundle))
if err != nil {
return c.Error(413, "Архив слишком большой")
}
plain, err := migrate.OpenRequest(m.key, box)
if err != nil {
if m.fails++; m.fails >= moveMaxFails {
m.key = nil
}
warnf("migrate: архив не расшифрован ip=%s", c.remoteIP())
return c.Error(403, "Код не подходит")
}
key := m.key
m.key = nil // код одноразовый
if why := m.canAccept(); why != "" {
return c.Error(409, why)
}
b, err := migrate.Read(bytes.NewReader(plain))
if err != nil {
return c.Error(400, err.Error())
}
defer b.Close()
host := m.host
plan, err := migrate.NewPlan(b, func(k string) int {
switch k {
case "SUB_PORT":
return m.a.sub.port
case "NODE_PORT":
return m.a.nodes.port
}
return 0
}, m.a.port, host)
if err != nil {
return c.Error(409, err.Error())
}
if ip := net.ParseIP(c.remoteIP()); ip != nil {
plan.TrustRelay(ip.String())
}
// Дальше файлы меняются: база закрывается (WAL сливается в файл до того, как его отложат).
paths := m.paths()
fail := func(err error) bool {
m.state, m.errText = moveImported, "Перенос прерван: "+err.Error()+". Прежние файлы — в *.bak-<время>."
warnf("migrate: %v", err)
return c.Error(500, m.errText)
}
m.a.db.Close()
secret := migrate.NewKey()[:16]
if err := migrate.Install(b, paths); err != nil {
return fail(err)
}
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
return fail(err)
}
pin, err := migrate.CertPin(paths["node_tls.crt"])
if err != nil {
return fail(fmt.Errorf("сертификат хаба: %w", err))
}
if err := envfile.Set(m.envFile, plan.Env); err != nil {
return fail(err)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
m.state = moveImported
logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP())
m.run("restart")
resp := jsjson.MustMarshal(jsjson.Object{{Key: "join", Val: join}})
c.W.Header().Set("Content-Type", "application/octet-stream")
c.W.WriteHeader(200)
c.W.Write(migrate.SealResponse(key, resp))
return false
}
// ── Старая панель ───────────────────────────────────────────────────────────
func (m *Migration) export(c *Ctx) bool {
if why := m.canExport(); why != "" {
return c.Error(409, why)
}
codeStr, _ := c.Obj().Get2("code").(string)
code, err := migrate.ParseCode(codeStr)
if err != nil {
return c.Error(400, err.Error())
}
m.mu.Lock()
if m.state != moveIdle {
m.mu.Unlock()
return c.Error(409, "Перенос уже идёт")
}
m.state, m.errText = moveSending, ""
m.mu.Unlock()
end := func(state, errText string) {
m.mu.Lock()
m.state, m.errText = state, errText
m.mu.Unlock()
m.a.sub.frozen.Store(state != moveIdle)
}
// Пока архив в пути, подписка отвечает «недоступна» (клиенты держат туннель и повторят):
// регистрация или смена ключа сейчас остались бы только в старой базе.
m.a.sub.frozen.Store(true)
var buf bytes.Buffer
if err := migrate.Write(&buf, m.a.db, m.paths(), m.exportMeta()); err != nil {
end(moveIdle, "")
return c.Error(500, "Архив не собран: "+err.Error())
}
logf("migrate: архив %d КБ → %s", buf.Len()/1024, code.URL())
cl := &http.Client{Timeout: moveTimeout, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Post(code.URL(), "application/octet-stream", bytes.NewReader(migrate.SealRequest(code.Key, buf.Bytes())))
if err != nil {
// Не дозвонились — новая точно ничего не получила. Иначе архив мог дойти.
var op *net.OpError
if errors.As(err, &op) && op.Op == "dial" {
end(moveIdle, "")
return c.Error(502, "Новая панель не отвечает: "+err.Error())
}
return m.uncertain(c, end, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != 200 {
end(moveIdle, "")
msg := "Новая панель отказала (" + strconv.Itoa(resp.StatusCode) + ")"
if v, derr := jsjson.Decode(raw); derr == nil {
if e, ok := field(v, "error").(string); ok {
msg = "Новая панель: " + e
}
}
return c.Error(502, msg)
}
var plain []byte
if err == nil {
plain, err = migrate.OpenResponse(code.Key, raw)
}
var join string
if err == nil {
v, derr := jsjson.Decode(plain)
join, _ = field(v, "join").(string)
if derr != nil || !strings.HasPrefix(join, "awgjoin://") {
err = errors.New("ответ без join-строки")
}
}
if err != nil {
return m.uncertain(c, end, err)
}
if err := envfile.Set(m.envFile, [][2]string{{"PANEL_MOVED", "on"}}); err != nil {
return m.uncertain(c, end, err)
}
target := strings.TrimSuffix(code.URL(), "/migrate/v1/import")
m.mu.Lock()
m.target = target
m.mu.Unlock()
end(moveMoved, "")
logf("migrate: новая панель приняла перенос — join, этот сервер станет нодой #0")
m.run("join", join)
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}, {Key: "target", Val: target}})
}
// uncertain — архив ушёл, а подтверждения нет. Подписка остаётся замороженной: если новая
// панель его приняла, вторая живая копия базы разошлась бы с ней.
func (m *Migration) uncertain(c *Ctx, end func(string, string), err error) bool {
msg := "Неизвестно, принят ли перенос (" + err.Error() + "). Откройте новую панель: если там есть сервер #0 — " +
"выполните здесь `awg-ctrl join` со строкой из его карточки; если нет — «Отменить перенос»."
end(moveUncertain, msg)
warnf("migrate: %s", msg)
return c.Error(502, msg)
}
func (m *Migration) cancel(c *Ctx) bool {
m.mu.Lock()
ok := m.state == moveUncertain
if ok {
m.state, m.errText = moveIdle, ""
}
m.mu.Unlock()
if !ok {
return c.Error(409, "Отменять нечего")
}
m.a.sub.frozen.Store(false)
logf("migrate: перенос отменён — подписка снова работает")
return m.status(c)
}
func (m *Migration) exportMeta() jsjson.Object {
name, ip := "VPN", os.Getenv("SERVER_IP")
if r, err := m.a.ctrlLocal("GET", "/health", nil); err == nil {
if s, ok := field(r.Data, "server").(string); ok && s != "" {
name = s
}
if s, ok := field(r.Data, "ip").(string); ok && ip == "" {
ip = s
}
}
return 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},
}
}
// spawn — `awg-control <args>` отдельным процессом через секунду (ответ успеет уйти). Окружение —
// чистое: CLI читает cli.env заново, а унаследованные значения перекрыли бы новые.
func (m *Migration) spawn(args ...string) {
exe, err := os.Executable()
if err != nil {
warnf("migrate: %v", err)
return
}
var env []string
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
if k == "PATH" || k == "HOME" || k == "LANG" || k == "TZ" || k == "AWG_ROOT" || strings.HasPrefix(k, "LC_") {
env = append(env, kv)
}
}
logFile := filepath.Join(filepath.Dir(m.a.baseDir), "logs", "awg-migrate.log")
go func() {
time.Sleep(time.Second)
cmd := exec.Command(exe, args...)
cmd.Env, cmd.Dir = env, "/"
if f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
cmd.Stdout, cmd.Stderr = f, f
defer f.Close()
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // переживёт эту панель
if err := cmd.Start(); err != nil {
warnf("migrate: awg-control %s: %v", args[0], err)
return
}
cmd.Wait()
}()
}
+174
View File
@@ -0,0 +1,174 @@
package ui
import (
"bufio"
"io"
"net"
"path/filepath"
"strings"
"testing"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testApp(t *testing.T, dir, local string) *App {
t.Helper()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
return a
}
// Перенос панели (`awg-control migrate`): та же ui.db на панели без VPN, старый сервер — нода
// #0. Клиент должен получить тот же конфиг байт в байт, вместе с rev, иначе он пересоздал бы
// туннель, а сервис на macOS — ещё и ключ.
func TestMigratedConfigIsByteIdentical(t *testing.T) {
oldDir, newDir := t.TempDir(), t.TempDir()
old := testApp(t, oldDir, "on")
s := &Sub{a: old, host: "203.0.113.7", port: 40123}
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (1, 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'Нода', 'pub', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0'), (5, 1, 'WG', '10.9.0.9', 'PSK1')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES
(0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0","dns":["1.1.1.1"],"mtu":1376,"gen":"3.1","awg":{"Jc":"4"}}', 1),
(1, '{"name":"Нода","endpoint":"192.0.2.2:47619","server_pub":"S1","gen":"2"}', 1)`,
} {
if _, err := old.db.Exec(q); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
dev := s.deviceByID(old.db, 5)
before := jsjson.MustMarshal(s.buildConfig(dev, nil))
if !strings.Contains(string(before), `"id":0`) || !strings.Contains(string(before), `"id":1`) {
t.Fatalf("в конфиге до переноса нет обоих серверов: %s", before)
}
if _, err := old.db.Exec("VACUUM INTO ?", filepath.Join(newDir, "ui.db")); err != nil {
t.Fatal(err)
}
// Как migrate import: нода #0 ещё не подключалась.
pre := testApp(t, newDir, "off")
if _, err := pre.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, 'VPN', x'00', 9999999999, 1)"); err != nil {
t.Fatal(err)
}
moved := testApp(t, newDir, "off")
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if s2.panelID != s.panelID {
t.Errorf("panel_id: %s → %s — пиры на старом сервере остались бы без хозяина", s.panelID, s2.panelID)
}
after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(moved.db, 5), nil))
if string(before) != string(after) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) || moved.nodes.IsOnline(0) || moved.nodes.DefaultID() != 0 {
t.Errorf("нода #0: exists=%v online=%v default=%d", moved.nodes.Exists(0), moved.nodes.IsOnline(0), moved.nodes.DefaultID())
}
if ids := moved.nodes.IDs(); len(ids) != 2 || ids[0] != 0 || ids[1] != 1 {
t.Errorf("IDs = %v", ids)
}
if r, _ := moved.nodes.CtrlFor(0)("GET", "/health", nil); r.Status != 502 {
t.Errorf("нода #0 не на связи: %d, ожидали 502", r.Status)
}
if l := moved.nodes.List(); len(l) != 2 || l[0].Local || !l[0].Pending {
t.Errorf("List: %+v", l)
}
// Без SUB_HOST адрес подписки — не endpoint ноды #0: это другая машина.
s2.host = ""
if h := s2.subHost(nil); h != "" {
t.Errorf("subHost = %q, ожидали пусто", h)
}
// Свой VPN и нода #0 — один id: такую базу с LOCAL_NODE=on не открываем.
t.Setenv("LOCAL_NODE", "on")
if _, err := newNodes(&App{db: moved.db, baseDir: newDir}); err == nil {
t.Error("LOCAL_NODE=on при ноде #0: ожидали ошибку")
}
}
func TestParseProxyV1(t *testing.T) {
for line, want := range map[string]string{
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "192.0.2.1:51000",
"PROXY TCP6 2001:db8::1 2001:db8::2 51000 40123": "[2001:db8::1]:51000",
"PROXY UNKNOWN": "unknown",
"PROXY UNKNOWN ffff::1 ffff::2 1 2": "unknown",
"PROXY TCP4 2001:db8::1 198.51.100.2 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 70000 1": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 1": "",
"PROXY TCP5 192.0.2.1 198.51.100.2 1 2": "",
"GET / HTTP/1.1": "",
"PROXY TCP4 192.0.2.1 not-an-ip 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "",
} {
addr, ok := parseProxyV1(line)
got := ""
switch {
case ok && addr == nil:
got = "unknown"
case ok:
got = addr.String()
}
if got != want {
t.Errorf("%q → %q, ожидали %q", line, got, want)
}
}
}
// Заголовок PROXY — только от доверенного адреса; соединение с него без заголовка проходит как
// есть, а от остальных заголовок остаётся байтами потока.
func TestProxyListener(t *testing.T) {
try := func(trusted bool, send string) (remote, data string) {
t.Helper()
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer raw.Close()
ln := newProxyListener(raw, map[string]bool{"127.0.0.1": trusted})
cl, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, send)
cl.(*net.TCPConn).CloseWrite()
done := make(chan net.Conn, 1)
go func() { c, _ := ln.Accept(); done <- c }()
select {
case c := <-done:
defer c.Close()
b, _ := io.ReadAll(bufio.NewReader(c))
return c.RemoteAddr().String(), string(b)
case <-time.After(3 * time.Second):
t.Fatal("Accept не вернулся")
}
return "", ""
}
if r, d := try(true, "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"); r != "192.0.2.1:51000" || d != "hello" {
t.Errorf("доверенный с заголовком: %s %q", r, d)
}
if r, d := try(true, "GET / HTTP/1.1\r\n"); !strings.HasPrefix(r, "127.0.0.1:") || d != "GET / HTTP/1.1\r\n" {
t.Errorf("доверенный без заголовка: %s %q", r, d)
}
hdr := "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"
if r, d := try(false, hdr); !strings.HasPrefix(r, "127.0.0.1:") || d != hdr {
t.Errorf("чужой с заголовком: %s %q", r, d)
}
}
+168
View File
@@ -0,0 +1,168 @@
package ui
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
type movePanel struct {
a *App
s *Sub
env string
calls [][]string
}
func newMovePanel(t *testing.T, local, nodePort string) *movePanel {
t.Helper()
dir := t.TempDir()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", nodePort)
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir, port: 34001}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
a.sub = &Sub{a: a, host: "203.0.113.7", port: 40123, locks: map[int]*sync.Mutex{}}
if err := a.sub.initSchema(); err != nil {
t.Fatal(err)
}
p := &movePanel{a: a, s: a.sub, env: filepath.Join(dir, "cli.env")}
a.migration = newMigration(a)
a.migration.envFile = p.env
a.migration.run = func(args ...string) { p.calls = append(p.calls, args) }
return p
}
func (p *movePanel) call(t *testing.T, h handler, body jsjson.Object) (int, jsjson.Object) {
t.Helper()
w := httptest.NewRecorder()
h(&Ctx{W: w, R: httptest.NewRequest("POST", "/", nil), Body: body})
v, _ := jsjson.Decode(w.Body.Bytes())
o, _ := v.(jsjson.Object)
return w.Code, o
}
// Вкладка «Перенос» целиком: код новой панели → архив из старой по HTTP → нода #0, join и
// перезапуск. У клиента конфиг тот же, у старой панели подписка заморожена до join.
func TestMigrateBetweenPanels(t *testing.T) {
old := newMovePanel(t, "on", "")
os.WriteFile(filepath.Join(old.a.baseDir, "sub_sign.key"), []byte("ключ подписи"), 0o600)
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0"}', 1)`,
} {
if _, err := old.a.db.Exec(q); err != nil {
t.Fatal(err)
}
}
before := jsjson.MustMarshal(old.s.buildConfig(old.s.deviceByID(old.a.db, 5), nil))
fresh := newMovePanel(t, "off", "40500")
m := fresh.a.migration
if why := m.canAccept(); why != "" {
t.Fatalf("чистая панель без VPN не принимает: %s", why)
}
if why := old.a.migration.canAccept(); why == "" {
t.Error("панель со своим VPN принимает перенос")
}
if why := fresh.a.migration.canExport(); why == "" {
t.Error("панель без VPN отдаёт перенос")
}
rt := newRouter()
rt.Post("/migrate/v1/import", m.importHandler)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, raw, _, _ := readBody(r, rt.bodyLimit)
rt.routes[0].hs[0](&Ctx{W: w, R: r, Body: body, RawBody: raw})
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port, _ := strconv.Atoi(u.Port())
m.key, m.host, m.expires = migrate.NewKey(), "127.0.0.1", nowFn().Add(moveCodeTTL)
// Чужой ключ: архив не принят, код живёт до пятой неудачи.
wrong := migrate.Code{Host: "127.0.0.1", Port: port, Key: migrate.NewKey()}.String()
if st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: wrong}}); st != 502 || !strings.Contains(o.Get2("error").(string), "Код не подходит") {
t.Fatalf("чужой код: %d %v", st, o)
}
if old.s.frozen.Load() || m.key == nil {
t.Fatal("после отказа подписка заморожена или код сгорел")
}
code := migrate.Code{Host: "127.0.0.1", Port: port, Key: m.key}.String()
st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: code}})
if st != 200 || o.Get2("target") != "http://127.0.0.1:"+u.Port() {
t.Fatalf("export: %d %v", st, o)
}
if !old.s.frozen.Load() {
t.Error("старая подписка не заморожена до join")
}
if len(old.calls) != 1 || old.calls[0][0] != "join" {
t.Fatalf("старая панель: %v", old.calls)
}
j, _ := jsjson.Decode(fromB64url(strings.TrimPrefix(old.calls[0][1], "awgjoin://")))
if field(j, "n") != 0.0 || field(j, "p") != 40500.0 || field(j, "sub") != 40123.0 || field(j, "ui") != 34001.0 {
t.Errorf("join: %s", jsjson.MustMarshal(j))
}
if raw, _ := os.ReadFile(old.env); !strings.Contains(string(raw), "PANEL_MOVED=on") {
t.Errorf("старый cli.env: %q", raw)
}
if len(fresh.calls) != 1 || fresh.calls[0][0] != "restart" || m.state != moveImported || m.key != nil {
t.Errorf("новая панель: %v, state %q", fresh.calls, m.state)
}
raw, _ := os.ReadFile(fresh.env)
for _, want := range []string{"SUB_PORT=40123", "NODE_PORT=40500", "SUB_HOST=127.0.0.1", "RELAY_FROM=127.0.0.1"} {
if !strings.Contains(string(raw), want) {
t.Errorf("новый cli.env без %s: %q", want, raw)
}
}
if k, _ := os.ReadFile(filepath.Join(fresh.a.baseDir, "sub_sign.key")); string(k) != "ключ подписи" {
t.Errorf("ключ подписи: %q", k)
}
// Новая панель после перезапуска: та же база + нода #0 → тот же конфиг.
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(fresh.a.baseDir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
moved := &App{db: db, baseDir: fresh.a.baseDir}
if moved.nodes, err = newNodes(moved); err != nil {
t.Fatal(err)
}
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(db, 5), nil)); string(after) != string(before) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) {
t.Error("нет ноды #0")
}
// Повторный приём: кода уже нет.
if st, _ := old.call(t, func(c *Ctx) bool { old.a.migration.state = moveIdle; return old.a.migration.export(c) },
jsjson.Object{{Key: "code", Val: code}}); st != 502 {
t.Errorf("второй перенос тем же кодом: %d", st)
}
}
+427
View File
@@ -0,0 +1,427 @@
package ui
// Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики.
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove`
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч / 7 дн / 30 дн
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
import (
"errors"
"io"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
type promRange struct{ seconds, step int64 }
// Шаг — чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
// Дальше 30 дней не заглядываем: столько Prometheus хранит (promRetention в internal/cli/monitor.go).
var promRanges = map[string]promRange{
"1h": {3600, 15}, "6h": {21600, 60}, "24h": {86400, 300},
"7d": {604800, 1800}, "30d": {2592000, 7200},
}
// Окно rate() — не короче шага: иначе на неделе/месяце точка была бы случайной двухминутной выборкой
// из двух часов, а не средним за них. Значения «сейчас» при этом считаются отдельно, по окну 2m.
const promRateWindow = "2m"
func withWindow(q, w string) string { return strings.ReplaceAll(q, "["+promRateWindow+"]", "["+w+"]") }
// rated — ряды, чьё «сейчас» показывает вкладка; при расширенном окне их берём мгновенным запросом.
var rated = []string{"down", "up", "cpu"}
// Запросы по серверам. Трафик — со счётчиков интерфейса (монотонны), а не с сумм по пирам.
var promQueries = []struct{ key, q string }{
{"down", `sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8`},
{"up", `sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8`},
{"online", `sum by (server_id, server) (awg_peers_online)`},
{"heavy", `sum by (server_id, server) (awg_shaping_heavy_devices)`},
{"drops", `sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))`},
{"congested", `max by (server_id, server) (awg_shaping_congested)`},
{"scrape_ok", `max by (server_id, server) (awg_scrape_ok)`},
{"cpu", `100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))`},
{"mem", `100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))`},
{"cores", `max by (server_id, server) (awg_host_cpu_cores)`},
{"mem_total", `max by (server_id, server) (awg_host_memory_total_bytes)`},
}
type point [2]float64
type seriesOne struct {
name any
points []point
}
// series — server_id → ряд, в порядке ответа Prometheus (как Map в JS).
type series struct {
ids []int
m map[int]*seriesOne
}
func (s *series) get(id int) *seriesOne {
if s == nil {
return nil
}
return s.m[id]
}
type Monitor struct {
a *App
promURL string
localEnabled bool
http *http.Client
}
func newMonitor(a *App) *Monitor {
return &Monitor{
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
localEnabled: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off",
http: &http.Client{Transport: &http.Transport{Proxy: nil}},
}
}
var errProm = errors.New("Prometheus не отвечает")
func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, error) {
q := url.Values{}
q.Set("query", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("step", strconv.FormatInt(step, 10))
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode())
if err != nil {
return nil, errProm
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errProm
}
out := &series{m: map[int]*seriesOne{}}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
metric := field(r, "metric")
idf := jsToNumberAny(field(metric, "server_id"))
if field(metric, "server_id") == nil {
idf = math.NaN()
}
if idf != float64(int(idf)) {
continue
}
id := int(idf)
name := field(metric, "server")
if name == nil {
name = strconv.Itoa(id)
}
s := &seriesOne{name: name}
for _, v := range arr(field(r, "values")) {
pair := arr(v)
if len(pair) < 2 {
continue
}
t := jsToNumberAny(pair[0])
val := jsToNumberAny(pair[1])
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
if math.IsNaN(val) || math.IsInf(val, 0) {
continue
}
s.points = append(s.points, point{t, val})
}
if _, dup := out.m[id]; !dup {
out.ids = append(out.ids, id)
}
out.m[id] = s
}
return out, nil
}
// instantQuery — значение на момент time по каждому server_id.
func (m *Monitor) instantQuery(query string, time_ int64) (map[int]float64, error) {
q := url.Values{}
q.Set("query", query)
q.Set("time", strconv.FormatInt(time_, 10))
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
resp, err := c.Get(m.promURL + "/api/v1/query?" + q.Encode())
if err != nil {
return nil, errProm
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errProm
}
out := map[int]float64{}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
metric := field(r, "metric")
if field(metric, "server_id") == nil {
continue
}
idf := jsToNumberAny(field(metric, "server_id"))
pair := arr(field(r, "value"))
if idf != float64(int(idf)) || len(pair) < 2 {
continue
}
if v := jsToNumberAny(pair[1]); !math.IsNaN(v) && !math.IsInf(v, 0) {
out[int(idf)] = v
}
}
return out, nil
}
// total — сумма (или максимум) рядов по меткам времени.
func total(s *series, max bool) []any {
acc := map[float64]float64{}
var order []float64
for _, id := range s.ids {
for _, p := range s.m[id].points {
cur, seen := acc[p[0]]
if !seen {
order = append(order, p[0])
}
if max {
acc[p[0]] = math.Max(cur, p[1])
} else {
acc[p[0]] = cur + p[1]
}
}
}
sort.Float64s(order)
out := []any{}
for _, t := range order {
out = append(out, []any{t, acc[t]})
}
return out
}
func last(s *seriesOne) any {
if s == nil || len(s.points) == 0 {
return nil
}
return s.points[len(s.points)-1][1]
}
func (m *Monitor) promReady() bool {
c := &http.Client{Transport: m.http.Transport, Timeout: 1500 * time.Millisecond}
resp, err := c.Get(m.promURL + "/-/ready")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
type monitorJob struct {
can bool
action any
running bool
exit any
log string
}
// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут.
func (m *Monitor) jobState() monitorJob {
if !m.localEnabled {
return monitorJob{}
}
r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil)
if err != nil || r.Status != 200 {
return monitorJob{}
}
j := monitorJob{can: true, action: field(r.Data, "action"), running: truthy(field(r.Data, "running")), exit: field(r.Data, "exit")}
switch l := field(r.Data, "log").(type) {
case nil:
case string:
j.log = l
default:
j.log, _ = jsString(l)
}
return j
}
func (m *Monitor) router() *Router {
rt := newRouter()
rt.Get("/", func(c *Ctx) bool {
var ready bool
var job monitorJob
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); ready = m.promReady() }()
go func() { defer wg.Done(); job = m.jobState() }()
wg.Wait()
var jobOut any
if job.can && truthy(job.action) {
jobOut = jsjson.Object{{Key: "action", Val: job.action}, {Key: "running", Val: job.running}, {Key: "exit", Val: job.exit}, {Key: "log", Val: job.log}}
}
return c.JSON(200, jsjson.Object{{Key: "ready", Val: ready}, {Key: "can_install", Val: job.can}, {Key: "job", Val: jobOut},
{Key: "command", Val: "awg-ctrl monitor install"}})
})
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
ask := func(action string) handler {
return func(c *Ctx) bool {
if !m.localEnabled {
return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине")
}
r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil)
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
if r.Status == 404 {
return c.Error(501, "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную")
}
return c.JSON(r.Status, r.Data)
}
}
rt.Post("/install", ask("install"))
rt.Post("/remove", ask("remove"))
rt.Get("/overview", func(c *Ctx) bool {
qs := c.R.URL.Query()
rangeKey := "1h"
if _, ok := qs["range"]; ok {
rangeKey = strings.Join(qs["range"], ",")
}
rg, ok := promRanges[rangeKey]
if !ok {
return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d")
}
end := now()
start := end - rg.seconds
window := promRateWindow
if rg.step > 120 {
window = strconv.FormatInt(rg.step, 10) + "s"
}
got := make([]*series, len(promQueries))
errs := make([]error, len(promQueries)+2+len(rated))
// Объём за весь период: increase() по тем же монотонным счётчикам (сбросы счётчика он учитывает).
// Окно расширено — «сейчас» с конца ряда было бы средним за шаг; берём мгновенные значения по 2m.
var usedDown, usedUp map[int]float64
nowVals := map[string]map[int]float64{}
var mu sync.Mutex
var wg sync.WaitGroup
for i, pq := range promQueries {
wg.Add(1)
go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(withWindow(pq.q, window), start, end, rg.step) }()
}
span := strconv.FormatInt(rg.seconds, 10) + "s"
n := len(promQueries)
wg.Add(2)
go func() {
defer wg.Done()
usedDown, errs[n] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
}()
go func() {
defer wg.Done()
usedUp, errs[n+1] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
}()
if window != promRateWindow {
for j, k := range rated {
for _, pq := range promQueries {
if pq.key != k {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
v, err := m.instantQuery(pq.q, end)
errs[n+2+j] = err
mu.Lock()
nowVals[k] = v
mu.Unlock()
}()
}
}
}
wg.Wait()
for _, err := range errs {
if err != nil {
return c.Error(502, err.Error())
}
}
used := func(v map[int]float64, id int) any {
if x, ok := v[id]; ok {
return x
}
return nil
}
// Вкладка показывает один сервер — выбранный в сервер-баре (?server=<id>).
if _, has := qs["server"]; has {
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) {
only := int(f)
for i, s := range got {
f := &series{m: map[int]*seriesOne{}}
if x, ok := s.m[only]; ok {
f.ids, f.m[only] = []int{only}, x
}
got[i] = f
}
}
}
s := map[string]*series{}
for i, pq := range promQueries {
s[pq.key] = got[i]
}
cur := func(k string, id int) any {
if v, ok := nowVals[k]; ok {
if x, ok := v[id]; ok {
return x
}
return nil
}
return last(s[k].get(id))
}
ids := map[int]bool{}
for _, x := range got {
for _, id := range x.ids {
ids[id] = true
}
}
var sorted []int
for id := range ids {
sorted = append(sorted, id)
}
sort.Ints(sorted)
servers := []any{}
for _, id := range sorted {
var name any = strconv.Itoa(id)
for _, k := range []string{"scrape_ok", "down", "online"} {
if x := s[k].get(id); x != nil {
name = x.name
break
}
}
servers = append(servers, jsjson.Object{
{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "ok", Val: last(s["scrape_ok"].get(id)) == 1.0},
{Key: "down", Val: cur("down", id)}, {Key: "up", Val: cur("up", id)},
{Key: "online", Val: last(s["online"].get(id))}, {Key: "heavy", Val: last(s["heavy"].get(id))},
{Key: "congested", Val: last(s["congested"].get(id)) == 1.0}, {Key: "cpu", Val: cur("cpu", id)},
{Key: "mem", Val: last(s["mem"].get(id))}, {Key: "cores", Val: last(s["cores"].get(id))},
{Key: "mem_total", Val: last(s["mem_total"].get(id))},
{Key: "used_down", Val: used(usedDown, id)}, {Key: "used_up", Val: used(usedUp, id)},
})
}
var rangeOut any = "1h"
if _, ok := qs["range"]; ok {
rangeOut = rangeKey
}
return c.JSON(200, jsjson.Object{
{Key: "range", Val: rangeOut}, {Key: "step", Val: rg.step}, {Key: "from", Val: start}, {Key: "to", Val: end},
{Key: "servers", Val: servers},
{Key: "total", Val: jsjson.Object{
{Key: "down", Val: total(s["down"], false)}, {Key: "up", Val: total(s["up"], false)}, {Key: "online", Val: total(s["online"], false)},
{Key: "heavy", Val: total(s["heavy"], false)}, {Key: "drops", Val: total(s["drops"], false)},
{Key: "cpu", Val: total(s["cpu"], true)}, {Key: "mem", Val: total(s["mem"], true)},
}},
})
})
return rt
}
+897
View File
@@ -0,0 +1,897 @@
package ui
// Несколько серверов под одной панелью (порт awg-ui/nodes.ts): core (эта панель) и ноды —
// VPN-серверы, которые сами открывают к core исходящее WSS-соединение (awg-control agent).
// Реестр нод лежит в ui.db, RPC к ноде идёт по этому соединению, а нода исполняет запрос на
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
//
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает
// как чистая панель. Номер 0 тогда может занять нода — сервер, с которого панель переехала
// (`awg-control migrate`): так у его пиров, ключей API и устройств не меняется id сервера.
// Протокол и формат join-строки — docs/node-protocol.md.
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"database/sql"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
joinTTL = 3600 // секунд жизни одноразового секрета
authTimeout = 10 * time.Second
healthEvery = 15 * time.Second
pingEvery = 20 * time.Second
rpcTimeout = 15 * time.Second
rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго
hubMaxFrame = 4 * 1024 * 1024
failWindow = 15 * time.Minute
failMaxHits = 20
)
var (
reNodeName = regexp.MustCompile(`^[^\r\n<>]+$`) // длину — lenOK: в JS {1,40} считает UTF-16
reHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`)
reVersion = regexp.MustCompile(`^[\w.+-]{1,32}$`)
)
// authMsg — сообщение, которое нода подписывает своим ключом. Дублируется в internal/agent.
func authMsg(nonce string, nodeID int64) []byte {
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.FormatInt(nodeID, 10))
}
type nodeRow struct {
ID int64
Name string
Pub sql.NullString
JoinHash []byte
JoinExpires sql.NullInt64
CreatedAt int64
LastSeen sql.NullInt64
Version sql.NullString
}
const nodeCols = "id, name, pub, join_hash, join_expires, created_at, last_seen, version"
func scanNode(row interface{ Scan(...any) error }) (*nodeRow, error) {
n := &nodeRow{}
if err := row.Scan(&n.ID, &n.Name, &n.Pub, &n.JoinHash, &n.JoinExpires, &n.CreatedAt, &n.LastSeen, &n.Version); err != nil {
return nil, err
}
return n, nil
}
// health — сводка /health сервера; ip — как пришло (`d.ip ?? ""`).
type health struct {
Server string
IP any
Gen any
Peers any
AwgUp bool
Module any
Tools any
}
func (h *health) json() any {
if h == nil {
return nil
}
return jsjson.Object{{Key: "server", Val: h.Server}, {Key: "ip", Val: h.IP}, {Key: "gen", Val: h.Gen}, {Key: "peers", Val: h.Peers},
{Key: "awg_up", Val: h.AwgUp}, {Key: "module", Val: h.Module}, {Key: "tools", Val: h.Tools}}
}
type pending struct {
ch chan *CtrlResp
timer *time.Timer
}
type nodeConn struct {
ws *websocket.Conn
mu sync.Mutex // seq, pending, alive
seq int64
alive bool
pending map[int64]*pending
closed bool
}
type deleteVeto struct {
Error string
Forcible bool
}
type Nodes struct {
a *App
port int
local bool
tls *tls.Config
pin []byte
mu sync.Mutex
conns map[int64]*nodeConn
health map[int64]*health
failHits map[string]*rateEntry
hmu sync.Mutex
onlineHandler []func(id int)
beforeDelete []func(id int, force bool) *deleteVeto
afterDelete []func(id int)
}
var offline = &CtrlResp{502, errObj("Сервер недоступен")}
func newNodes(a *App) (*Nodes, error) {
n := &Nodes{
a: a, port: envNum("NODE_PORT"),
local: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off",
conns: map[int64]*nodeConn{}, health: map[int64]*health{}, failHits: map[string]*rateEntry{},
}
file := func(env, name string) string {
if v := os.Getenv(env); v != "" {
return v
}
return filepath.Join(a.baseDir, name)
}
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же,
// сколько 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) {
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node")
if err := cmd.Run(); err != nil {
return err
}
os.Chmod(keyF, 0o600)
os.Chmod(crtF, 0o600)
logf("nodes: создан самоподписанный сертификат %s", crtF)
}
crt, err := os.ReadFile(crtF)
if err != nil {
return err
}
cert, err := tls.LoadX509KeyPair(crtF, keyF)
if err != nil {
return err
}
pin, err := spkiPin(crt)
if err != nil {
return err
}
n.tls = &tls.Config{Certificates: []tls.Certificate{cert}}
n.pin = pin
return nil
}(); err != nil {
warnf("nodes: сертификат хаба недоступен (%s) — приём нод отключён", crtF)
n.tls, n.pin = nil, nil
}
}
if _, err := a.db.Exec(`
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
pub TEXT,
join_hash BLOB,
join_expires INTEGER,
created_at INTEGER NOT NULL,
last_seen INTEGER
);
`); err != nil {
return nil, err
}
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают).
if !hasColumn(a.db, "nodes", "version") {
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN version TEXT"); err != nil {
return nil, err
}
}
// core_ack — адрес панели, который агент записал к себе (кадр core_ok): нода переживёт
// выключение сервера, через который подключена сейчас.
if !hasColumn(a.db, "nodes", "core_ack") {
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN core_ack TEXT"); 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 — " +
"у этой панели свой VPN не включают")
}
return n, nil
}
func exists(f string) bool {
_, err := os.Stat(f)
return err == nil
}
func hasColumn(db queryer, table, col string) bool {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return false
}
defer rows.Close()
cols, _ := rows.Columns()
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
for rows.Next() {
rows.Scan(ptrs...)
for i, c := range cols {
if c == "name" && fmt.Sprint(vals[i]) == col {
return true
}
}
}
return false
}
func (n *Nodes) row(id int64) *nodeRow {
r, _ := scanNode(n.a.db.QueryRow("SELECT "+nodeCols+" FROM nodes WHERE id = ?", id))
return r
}
func (n *Nodes) all() []*nodeRow {
rows, err := n.a.db.Query("SELECT " + nodeCols + " FROM nodes ORDER BY id")
if err != nil {
return nil
}
defer rows.Close()
var out []*nodeRow
for rows.Next() {
if r, err := scanNode(rows); err == nil {
out = append(out, r)
}
}
return out
}
// ── RPC к нодам ─────────────────────────────────────────────────────────────
func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp {
n.mu.Lock()
c := n.conns[id]
n.mu.Unlock()
if c == nil {
return offline
}
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return offline
}
c.seq++
rid := c.seq
p := &pending{ch: make(chan *CtrlResp, 1)}
timeout := rpcTimeout
if strings.HasPrefix(path, "/awg/") {
timeout = rpcSlow
}
p.timer = time.AfterFunc(timeout, func() {
c.mu.Lock()
delete(c.pending, rid)
c.mu.Unlock()
p.ch <- &CtrlResp{504, errObj("Сервер не ответил")}
})
c.pending[rid] = p
c.mu.Unlock()
msg := jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: rid}, {Key: "method", Val: method}, {Key: "path", Val: path}}
if body != nil {
msg = append(msg, jsjson.KV{Key: "body", Val: body})
}
if b, err := jsjson.Marshal(msg); err == nil {
go c.ws.Write(context.Background(), websocket.MessageText, b)
}
return <-p.ch
}
// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS.
func (n *Nodes) CtrlFor(id int) Ctrl {
if id == 0 && n.local {
return n.a.ctrlLocal
}
if n.row(int64(id)) == nil {
if id == 0 {
return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
}
}
return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Сервер не найден")}, nil
}
}
return func(m, p string, b any) (*CtrlResp, error) { return n.rpc(int64(id), m, p, b), nil }
}
func (n *Nodes) Exists(id int) bool {
if id == 0 && n.local {
return true
}
return n.row(int64(id)) != nil
}
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
func (n *Nodes) IsOnline(id int) bool {
if id == 0 && n.local {
return true
}
n.mu.Lock()
defer n.mu.Unlock()
return n.conns[int64(id)] != nil
}
// IDs — все серверы реестра: локальный (если есть) и ноды.
func (n *Nodes) IDs() []int {
var ids []int
if n.local {
ids = append(ids, 0)
}
for _, r := range n.all() {
ids = append(ids, int(r.ID))
}
return ids
}
// OnOnline — подписчики на «сервер снова на связи» (подписка догоняет пропущенное).
func (n *Nodes) OnOnline(cb func(id int)) {
n.hmu.Lock()
n.onlineHandler = append(n.onlineHandler, cb)
n.hmu.Unlock()
}
// BeforeDelete — проверка перед удалением сервера: может запретить (forcible — запрет
// снимается подтверждением оператора). AfterDelete — уборка ссылок на уже удалённый сервер.
func (n *Nodes) BeforeDelete(cb func(id int, force bool) *deleteVeto) {
n.hmu.Lock()
n.beforeDelete = append(n.beforeDelete, cb)
n.hmu.Unlock()
}
func (n *Nodes) AfterDelete(cb func(id int)) {
n.hmu.Lock()
n.afterDelete = append(n.afterDelete, cb)
n.hmu.Unlock()
}
func (n *Nodes) fireOnline(id int) {
n.hmu.Lock()
hs := append([]func(int){}, n.onlineHandler...)
n.hmu.Unlock()
for _, cb := range hs {
func() {
defer func() { recover() }() // подписчик сам разберётся
cb(id)
}()
}
}
// DefaultID — сервер по умолчанию: локальный, а без него — первая нода.
func (n *Nodes) DefaultID() int {
if n.local {
return 0
}
if all := n.all(); len(all) > 0 {
return int(all[0].ID)
}
return 0
}
func (n *Nodes) refreshHealth(id int64) {
var r *CtrlResp
if id == 0 && n.local {
r, _ = n.a.ctrlLocal("GET", "/health", nil)
} else {
r = n.rpc(id, "GET", "/health", nil)
}
var d any
if r != nil {
d = r.Data
}
n.mu.Lock()
defer n.mu.Unlock()
if server, ok := field(d, "server").(string); ok {
or := func(v any, def any) any {
if v == nil {
return def
}
return v
}
n.health[id] = &health{
Server: server, IP: or(field(d, "ip"), ""), Gen: or(field(d, "gen"), "2"), Peers: or(field(d, "awg", "peers"), 0.0),
AwgUp: field(d, "awg", "status") == "ok", Module: or(field(d, "awg", "module"), ""), Tools: or(field(d, "awg", "tools"), ""),
}
} else {
delete(n.health, id)
}
}
// ── Хаб: приём соединений от нод ────────────────────────────────────────────
func (n *Nodes) failed(ip string) {
n.mu.Lock()
defer n.mu.Unlock()
t := nowFn()
e := n.failHits[ip]
if e == nil || e.resetAt.Before(t) {
n.failHits[ip] = &rateEntry{1, t.Add(failWindow)}
} else {
e.count++
}
}
func (n *Nodes) blocked(ip string) bool {
n.mu.Lock()
defer n.mu.Unlock()
e := n.failHits[ip]
return e != nil && e.resetAt.After(nowFn()) && e.count >= failMaxHits
}
// dropConn — все ждущие запросы получают «недоступен»; из реестра соединений — только если
// там всё ещё это соединение.
func (n *Nodes) dropConn(id int64, c *nodeConn) {
c.mu.Lock()
c.closed = true
ps := c.pending
c.pending = map[int64]*pending{}
c.mu.Unlock()
for _, p := range ps {
if p.timer.Stop() {
p.ch <- offline
}
}
n.mu.Lock()
if n.conns[id] == c {
delete(n.conns, id)
delete(n.health, id)
}
n.mu.Unlock()
}
func closeWS(ws *websocket.Conn, code int, reason string) {
go ws.Close(websocket.StatusCode(code), reason)
}
func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
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)
var conn *nodeConn
var nodeID int64
authTimer := time.AfterFunc(authTimeout, func() { closeWS(ws, 4001, "auth_timeout") })
defer authTimer.Stop()
deny := func() { n.failed(ip); closeWS(ws, 4003, "unauthorized") }
authenticate := func(m jsjson.Object) {
sig, sigOK := m.Get2("sig").(string)
if m.Get2("t") != "auth" || !isInt(m.Get2("node_id")) || !sigOK {
deny()
return
}
row := n.row(int64(m.Get2("node_id").(float64)))
if row == nil {
deny()
return
}
pubB64 := row.Pub.String
joining := !row.Pub.Valid
if joining {
secret, sOK := 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 {
deny()
return
}
pubB64 = pub
}
key := fromB64url(pubB64)
if len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, authMsg(nonce, row.ID), fromB64url(sig)) {
deny()
return
}
authTimer.Stop()
if joining {
n.a.db.Exec("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?", pubB64, row.ID)
}
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
var version any
if v, ok := m.Get2("version").(string); ok && reVersion.MatchString(v) {
version = v
}
n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID)
conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}}
nodeID = row.ID
n.mu.Lock()
old := n.conns[row.ID]
n.conns[row.ID] = conn
n.mu.Unlock()
if old != nil {
n.dropConn(row.ID, old)
closeWS(old.ws, 4000, "replaced")
}
ready := jsjson.Object{{Key: "t", Val: "ready"}}
if home := n.home(); home != "" {
ready = append(ready, jsjson.KV{Key: "core", Val: home})
}
ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(ready))
first := ""
if joining {
first = " (первое подключение)"
}
logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first)
go n.refreshHealth(row.ID)
go n.fireOnline(int(row.ID))
}
hello, _ := jsjson.Marshal(jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: nonce}})
ws.Write(r.Context(), websocket.MessageText, hello)
ctx := context.Background()
for {
_, raw, err := ws.Read(ctx)
if err != nil {
break
}
v, err := jsjson.Decode(raw)
if err != nil {
closeWS(ws, 4002, "bad_frame")
continue
}
m, _ := v.(jsjson.Object)
if conn == nil {
authenticate(m)
continue
}
if m.Get2("t") == "core_ok" {
if home, ok := m.Get2("core").(string); ok && home == n.home() {
n.a.db.Exec("UPDATE nodes SET core_ack = ? WHERE id = ?", home, nodeID)
}
continue
}
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
rid := int64(m.Get2("id").(float64))
conn.mu.Lock()
p := conn.pending[rid]
delete(conn.pending, rid)
conn.mu.Unlock()
if p == nil || !p.timer.Stop() {
continue
}
st := jsToNumberAny(m.Get2("status"))
status := 502
if st == st && st != 0 {
status = int(st)
}
p.ch <- &CtrlResp{status, m.Get2("data")}
}
}
if conn != nil {
n.dropConn(nodeID, conn)
logf("nodes: #%d отключена", nodeID)
}
}
// Start поднимает хаб (если настроен) и периодические ping/health.
func (n *Nodes) Start() {
if n.local {
go n.refreshHealth(0)
go n.fireOnline(0)
}
if n.port == 0 || n.tls == nil {
return
}
mux := http.NewServeMux()
mux.HandleFunc("/node/v1", n.serveWS)
srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second,
ErrorLog: quietLog()}
go func() {
ln, err := listen(n.port)
if err != nil {
warnf("nodes: хаб не запущен: %v", err)
return
}
logf("nodes: hub on :%d (wss)", n.port)
srv.ServeTLS(ln, "", "")
}()
go func() {
for range time.Tick(pingEvery) {
n.mu.Lock()
conns := map[int64]*nodeConn{}
for id, c := range n.conns {
conns[id] = c
}
n.mu.Unlock()
for id, c := range conns {
c.mu.Lock()
alive := c.alive
c.alive = false
c.mu.Unlock()
if !alive {
c.ws.CloseNow()
n.dropConn(id, c)
continue
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), pingEvery)
defer cancel()
if c.ws.Ping(ctx) == nil {
c.mu.Lock()
c.alive = true
c.mu.Unlock()
}
}()
}
}
}()
go func() {
for range time.Tick(healthEvery) {
n.mu.Lock()
var ids []int64
for id := range n.conns {
ids = append(ids, id)
}
n.mu.Unlock()
for _, id := range ids {
go n.refreshHealth(id)
}
}
}()
}
// ── Роуты панели (/ui/nodes, за JWT) ────────────────────────────────────────
func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil }
// home — публичный адрес хаба, который он сообщает нодам в ready (агент звонит туда первым).
// Из SUB_HOST: у панели один публичный адрес; не задан — нодам хватает адреса из join.
func (n *Nodes) home() string {
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
if host == "" || !n.hubReady() {
return ""
}
return net.JoinHostPort(host, strconv.Itoa(n.port))
}
func (n *Nodes) joinString(host string, id int64, secret []byte) string {
b := jsjson.MustMarshal(jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: n.port},
{Key: "pin", Val: b64url.EncodeToString(n.pin)}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}})
return "awgjoin://" + b64url.EncodeToString(b)
}
var rePortSuffix = regexp.MustCompile(`:\d+$`)
func (n *Nodes) hostOf(c *Ctx) string {
if given, ok := c.Obj().Get2("host").(string); ok && reHost.MatchString(given) {
return given
}
h := rePortSuffix.ReplaceAllString(c.R.Host, "")
return strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
}
func nullStr(s sql.NullString) any {
if s.Valid {
return s.String
}
return nil
}
func orNil(s string) any {
if s == "" {
return nil
}
return s
}
// List — серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и метрик.
func (n *Nodes) List() []serverInfo {
var out []serverInfo
n.mu.Lock()
hs := map[int64]*health{}
for k, v := range n.health {
hs[k] = v
}
online := map[int64]bool{}
for k := range n.conns {
online[k] = true
}
n.mu.Unlock()
if n.local {
h := hs[0]
name := "VPN"
if h != nil {
name = h.Server
}
out = append(out, serverInfo{ID: 0, Name: name, Local: true, Online: h != nil, Version: orNil(n.a.version), Health: h})
}
for _, r := range n.all() {
out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid,
LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID]})
}
return out
}
type serverInfo struct {
ID int
Name string
Local bool
Online bool
Pending bool
LastSeen any
Version any
Health *health
}
func (s serverInfo) json() jsjson.Object {
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online},
{Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}}
}
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
f := jsToNumber(c.Params["id"])
return int64(f), f == float64(int64(f)) && (f >= 1 || f == 0 && !n.local)
}
func (n *Nodes) router() *Router {
rt := newRouter()
rt.Get("/", func(c *Ctx) bool {
if n.local {
n.refreshHealth(0)
}
list := []any{}
for _, s := range n.List() {
list = append(list, s.json())
}
return c.JSON(200, jsjson.Object{{Key: "hub", Val: n.hubReady()}, {Key: "local", Val: n.local},
{Key: "version", Val: orNil(n.a.version)}, {Key: "nodes", Val: list}})
})
rt.Post("/", func(c *Ctx) bool {
if !n.hubReady() {
return c.Error(503, "Приём нод не настроен (NODE_PORT / сертификат)")
}
name, ok := c.Obj().Get2("name").(string)
if !ok || !reNodeName.MatchString(name) || !lenOK(name, 1, 40) {
return c.Error(400, "Имя: до 40 символов, без переводов строк и <>")
}
host := n.hostOf(c)
if !reHost.MatchString(host) {
return c.Error(400, "Не удалось определить адрес core")
}
secret := make([]byte, 16)
rand.Read(secret)
h := sha256.Sum256(secret)
res, err := n.a.db.Exec("INSERT INTO nodes (name, join_hash, join_expires, created_at) VALUES (?, ?, ?, ?)", name, h[:], now()+joinTTL, now())
if err != nil {
return c.Error(500, "Internal server error")
}
id, _ := res.LastInsertId()
return c.JSON(201, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "join", Val: n.joinString(host, id, secret)}, {Key: "expires_in", Val: joinTTL}})
})
// Новая join-строка для ноды, которая ещё не подключалась (истёк срок / потеряли).
rt.Post("/:id/join", func(c *Ctx) bool {
if !n.hubReady() {
return c.Error(503, "Приём нод не настроен (NODE_PORT / сертификат)")
}
id, ok := n.idOf(c)
var row *nodeRow
if ok {
row = n.row(id)
}
if row == nil {
return c.Error(404, "Не найден")
}
if row.Pub.Valid {
return c.Error(409, "Нода уже подключена")
}
host := n.hostOf(c)
if !reHost.MatchString(host) {
return c.Error(400, "Не удалось определить адрес core")
}
secret := make([]byte, 16)
rand.Read(secret)
h := sha256.Sum256(secret)
n.a.db.Exec("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL", h[:], now()+joinTTL, id)
return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: row.Name}, {Key: "join", Val: n.joinString(host, id, secret)}, {Key: "expires_in", Val: joinTTL}})
})
rt.Patch("/:id", func(c *Ctx) bool {
id, ok := n.idOf(c)
if !ok || n.row(id) == nil {
return c.Error(404, "Не найден")
}
name, ok := c.Obj().Get2("name").(string)
if !ok || !reNodeName.MatchString(name) || !lenOK(name, 1, 40) {
return c.Error(400, "Неверное имя")
}
n.a.db.Exec("UPDATE nodes SET name = ? WHERE id = ?", name, id)
return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name}})
})
rt.Delete("/:id", func(c *Ctx) bool {
id, ok := n.idOf(c)
if !ok || n.row(id) == nil {
return c.Error(404, "Не найден")
}
// Ключи API и мастер-ключи проверяют их владельцы (BeforeDelete).
force := c.R.URL.Query().Get("force") == "1"
n.hmu.Lock()
before := append([]func(int, bool) *deleteVeto{}, n.beforeDelete...)
after := append([]func(int){}, n.afterDelete...)
n.hmu.Unlock()
for _, cb := range before {
veto := func() (v *deleteVeto) {
defer func() {
if recover() != nil {
v = &deleteVeto{"Не удалось подготовить сервер к удалению", true}
}
}()
return cb(int(id), force)
}()
if veto != nil {
return c.JSON(409, jsjson.Object{{Key: "error", Val: veto.Error}, {Key: "can_force", Val: veto.Forcible && !force}})
}
}
if n.row(id) == nil {
return c.Error(404, "Не найден")
}
n.mu.Lock()
conn := n.conns[id]
n.mu.Unlock()
if conn != nil {
n.dropConn(id, conn)
closeWS(conn.ws, 4004, "removed")
}
n.a.db.Exec("DELETE FROM nodes WHERE id = ?", id)
// Хвосты подписки для этого сервера больше некому доделать.
for _, t := range []string{"peer_tombstones", "server_profiles"} {
n.a.db.Exec("DELETE FROM "+t+" WHERE server_id = ?", id)
}
for _, cb := range after {
func() {
defer func() {
if p := recover(); p != nil {
warnf("nodes: уборка после удаления #%d: %v", id, p)
}
}()
cb(int(id))
}()
}
return c.JSON(200, jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: id}})
})
return rt
}
+149
View File
@@ -0,0 +1,149 @@
package ui
// PROXY protocol v1 от ретранслятора (`awg-control relay`): после переноса панели старый
// сервер пересылает на неё подписку, хаб нод и панель. Без заголовка все клиенты пришли бы с
// его IP и делили бы на всех лимиты по адресу (неудачные запросы, регистрации, вход). Заголовку
// верим только от адресов из RELAY_FROM; от остальных он — просто байты соединения.
import (
"bufio"
"net"
"os"
"strconv"
"strings"
"time"
)
const proxyHeaderTimeout = 5 * time.Second
// relayFrom — RELAY_FROM: IP ретрансляторов через запятую.
func relayFrom() map[string]bool {
out := map[string]bool{}
for _, s := range strings.Split(os.Getenv("RELAY_FROM"), ",") {
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
out[ip.String()] = true
}
}
return out
}
// listen — net.Listen("tcp", :port), а при заданном RELAY_FROM — с разбором PROXY v1.
func listen(port int) (net.Listener, error) {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(port))
if err != nil {
return nil, err
}
if trusted := relayFrom(); len(trusted) > 0 {
return newProxyListener(ln, trusted), nil
}
return ln, nil
}
type accepted struct {
c net.Conn
err error
}
// proxyListener читает заголовок в своей горутине на соединение: медленный ретранслятор не
// держит Accept для остальных.
type proxyListener struct {
net.Listener
trusted map[string]bool
ch chan accepted
}
func newProxyListener(ln net.Listener, trusted map[string]bool) *proxyListener {
l := &proxyListener{Listener: ln, trusted: trusted, ch: make(chan accepted)}
go l.loop()
return l
}
func (l *proxyListener) loop() {
for {
c, err := l.Listener.Accept()
if err != nil {
l.ch <- accepted{nil, err}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return
}
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
if ip := net.ParseIP(host); ip == nil || !l.trusted[ip.String()] {
l.ch <- accepted{c, nil}
continue
}
go func() {
if pc := readProxyHeader(c); pc != nil {
l.ch <- accepted{pc, nil}
}
}()
}
}
func (l *proxyListener) Accept() (net.Conn, error) {
a := <-l.ch
return a.c, a.err
}
type proxyConn struct {
net.Conn
r *bufio.Reader
remote net.Addr
}
func (c *proxyConn) Read(b []byte) (int, error) { return c.r.Read(b) }
func (c *proxyConn) RemoteAddr() net.Addr { return c.remote }
// readProxyHeader — соединение без заголовка остаётся как есть (с ретранслятора может прийти и
// обычный клиент); битый заголовок — закрываем. nil — соединение закрыто.
func readProxyHeader(c net.Conn) net.Conn {
c.SetReadDeadline(time.Now().Add(proxyHeaderTimeout))
defer c.SetReadDeadline(time.Time{})
r := bufio.NewReaderSize(c, 256)
pc := &proxyConn{Conn: c, r: r, remote: c.RemoteAddr()}
if sig, err := r.Peek(6); err != nil || string(sig) != "PROXY " {
if err != nil && len(sig) == 0 {
c.Close()
return nil
}
return pc
}
// Строка v1 — не длиннее 107 байт вместе с CRLF.
line, err := r.ReadSlice('\n')
if err != nil || len(line) > 107 || !strings.HasSuffix(string(line), "\r\n") {
c.Close()
return nil
}
if addr, ok := parseProxyV1(strings.TrimSuffix(string(line), "\r\n")); ok {
if addr != nil {
pc.remote = addr
}
return pc
}
c.Close()
return nil
}
// parseProxyV1 — «PROXY TCP4|TCP6 src dst sport dport» или «PROXY UNKNOWN …» (addr == nil).
func parseProxyV1(line string) (*net.TCPAddr, bool) {
f := strings.Split(line, " ")
if len(f) >= 2 && f[0] == "PROXY" && f[1] == "UNKNOWN" {
return nil, true
}
if len(f) != 6 || f[0] != "PROXY" || f[1] != "TCP4" && f[1] != "TCP6" {
return nil, false
}
ip := net.ParseIP(f[2])
if ip == nil || net.ParseIP(f[3]) == nil || (ip.To4() != nil) != (f[1] == "TCP4") {
return nil, false
}
port, err := strconv.Atoi(f[4])
if err != nil || port < 0 || port > 65535 {
return nil, false
}
if p, err := strconv.Atoi(f[5]); err != nil || p < 0 || p > 65535 {
return nil, false
}
return &net.TCPAddr{IP: ip, Port: port}, true
}
+102
View File
@@ -0,0 +1,102 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
// После переноса старый сервер выключают, когда новый адрес знают все: хаб сообщает его
// нодам в ready и запоминает подтверждение (core_ok), клиенты получают его в endpoints.
// Вкладка «Перенос» показывает, сколько осталось.
func TestRetireReadiness(t *testing.T) {
t.Setenv("SUB_HOST", "198.51.100.20")
p := newMovePanel(t, "off", "40500")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
for _, q := range []string{
"INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'Старый', 'x', 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', '" + b64url.EncodeToString(pub) + "', 1)",
"INSERT INTO nodes (id, name, created_at) VALUES (4, 'Ещё не подключена', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (2, 'u2', 'Не открыта', x'02', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (3, 'u3', 'Отозвана', x'03', 1, 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (5, 1, 'a', 'W5', 'A5', 1, 2000)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (6, 1, 'b', 'W6', 'A6', 1, 500)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (7, 1, 'c', 'W7', 'A7', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (8, 3, 'd', 'W8', 'A8', 1, 2000)",
} {
if _, err := p.a.db.Exec(q); err != nil {
t.Fatal(q, err)
}
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
t.Fatal(err)
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o
}
write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) }
nonce := read().Get2("nonce").(string)
write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}})
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
t.Fatalf("ready: %s", got)
}
retire := func() jsjson.Object {
_, o := p.call(t, p.a.migration.status, nil)
r, _ := o.Get2("retire").(jsjson.Object)
return r
}
if retire() != nil {
t.Error("готовность показана панели, которая ничего не принимала")
}
t.Setenv("MOVED_IN_AT", "1000")
if n := string(jsjson.MustMarshal(retire().Get2("nodes"))); n != `[{"id":3,"name":"Frankfurt","online":true,"ok":false}]` {
t.Errorf("до core_ok: %s", n)
}
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "203.0.113.66:1"}}) // не наш адрес — мимо
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "198.51.100.20:40500"}})
var r jsjson.Object
for range 50 {
if r = retire(); strings.Contains(string(jsjson.MustMarshal(r.Get2("nodes"))), `"ok":true`) {
break
}
time.Sleep(20 * time.Millisecond)
}
want := `{"since":1000,"home":"198.51.100.20:40500","old":{"exists":true,"online":false},` +
`"devices":{"total":3,"fresh":1},"unused_links":1,"nodes":[{"id":3,"name":"Frankfurt","online":true,"ok":true}]}`
if got := string(jsjson.MustMarshal(r)); got != want {
t.Errorf("готовность:\n got %s\nwant %s", got, want)
}
os.WriteFile(p.env, []byte("UI_PASS=x\nMOVED_IN_AT=1000\n"), 0o600)
w := httptest.NewRecorder()
p.a.migration.router().ServeHTTP(w, httptest.NewRequest("DELETE", "/retire", nil))
if raw, _ := os.ReadFile(p.env); strings.Contains(string(raw), "MOVED_IN_AT") || retire() != nil {
t.Errorf("отметка не убрана: %q", raw)
}
}
+430
View File
@@ -0,0 +1,430 @@
package ui
// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1 (порт awg-ui/sen.ts).
// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент реализуется по
// ней, поэтому любое изменение здесь — изменение протокола.
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"hash/crc32"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
senPrefix = "sen://"
senVersion = 1
flagTLS = 0x01
addrV4 = 0x04
addrV6 = 0x06
addrDomain = 0x44
)
type senLinkError struct{ msg string }
func (e *senLinkError) Error() string { return e.msg }
func senErr(f string, a ...any) error { return &senLinkError{fmt.Sprintf(f, a...)} }
type SenAddr struct {
Host string `json:"host"`
Port int `json:"port"`
}
type SenLink struct {
TLS bool
Addrs []SenAddr
Secret []byte // 16 байт
SignPub []byte // 32 байта, Ed25519
TLSPin []byte // 32 байта, только при TLS
Name string
}
// b64url — Buffer.toString("base64url"): без паддинга.
var b64url = base64.RawURLEncoding
// fromB64url повторяет Buffer.from(s, "base64url") в Node: терпит паддинг и «+/», хвостовой
// одиночный символ (неполный байт) молча отбрасывает, мусор после первого чужого символа — тоже.
func fromB64url(s string) []byte {
s = strings.NewReplacer("+", "-", "/", "_").Replace(s)
if i := strings.IndexFunc(s, func(r rune) bool {
return !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_')
}); i >= 0 {
s = s[:i]
}
if len(s)%4 == 1 {
s = s[:len(s)-1]
}
b, _ := b64url.DecodeString(s)
return b
}
// fromB64 — Buffer.from(s, "base64") (обычный алфавит, тоже нестрого).
func fromB64(s string) []byte {
return fromB64url(strings.TrimRight(s, "="))
}
// ── Адреса ─────────────────────────────────────────────────────────────────
var (
reHex4 = regexp.MustCompile(`^[0-9a-fA-F]{1,4}$`)
reIPv4 = regexp.MustCompile(`^\d{1,3}(\.\d{1,3}){3}$`)
reDomain = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
)
func ipv6ToBytes(host string) []byte {
parts := strings.Split(host, "::")
if len(parts) > 2 {
return nil
}
var head, tail []string
if parts[0] != "" {
head = strings.Split(parts[0], ":")
}
if len(parts) == 2 && parts[1] != "" {
tail = strings.Split(parts[1], ":")
}
missing := 8 - len(head) - len(tail)
if len(parts) == 1 && len(head) != 8 || len(parts) == 2 && missing < 1 {
return nil
}
groups := append([]string{}, head...)
if len(parts) == 2 {
for range missing {
groups = append(groups, "0")
}
}
groups = append(groups, tail...)
if len(groups) != 8 {
return nil
}
out := make([]byte, 16)
for i, g := range groups {
if !reHex4.MatchString(g) {
return nil
}
n, _ := strconv.ParseUint(g, 16, 16)
binary.BigEndian.PutUint16(out[i*2:], uint16(n))
}
return out
}
func bytesToIPv6(b []byte) string {
g := make([]string, 8)
for i := range 8 {
g[i] = strconv.FormatUint(uint64(binary.BigEndian.Uint16(b[i*2:])), 16)
}
// Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи.
bestStart, bestLen := -1, 0
for i := 0; i < 8; {
if g[i] != "0" {
i++
continue
}
j := i
for j < 8 && g[j] == "0" {
j++
}
if j-i > bestLen {
bestStart, bestLen = i, j-i
}
i = j
}
if bestLen < 2 {
return strings.Join(g, ":")
}
return strings.Join(g[:bestStart], ":") + "::" + strings.Join(g[bestStart+bestLen:], ":")
}
func encodeAddr(a SenAddr) ([]byte, error) {
if a.Port < 1 || a.Port > 65535 {
return nil, senErr("Неверный порт: %d", a.Port)
}
p := binary.BigEndian.AppendUint16(nil, uint16(a.Port))
if reIPv4.MatchString(a.Host) {
out := []byte{addrV4}
for _, o := range strings.Split(a.Host, ".") {
n, _ := strconv.Atoi(o)
if n > 255 {
return nil, senErr("Неверный IPv4: %s", a.Host)
}
out = append(out, byte(n))
}
return append(out, p...), nil
}
if strings.Contains(a.Host, ":") {
b := ipv6ToBytes(a.Host)
if b == nil {
return nil, senErr("Неверный IPv6: %s", a.Host)
}
return append(append([]byte{addrV6}, b...), p...), nil
}
if len(a.Host) < 1 || len(a.Host) > 253 || !reDomain.MatchString(a.Host) {
return nil, senErr("Неверный домен: %s", a.Host)
}
return append(append([]byte{addrDomain, byte(len(a.Host))}, a.Host...), p...), nil
}
// ── Ссылка ─────────────────────────────────────────────────────────────────
func EncodeSenLink(l SenLink) (string, error) {
if len(l.Addrs) < 1 || len(l.Addrs) > 3 {
return "", senErr("Адресов должно быть от 1 до 3")
}
if len(l.Secret) != 16 {
return "", senErr("secret: 16 байт")
}
if len(l.SignPub) != 32 {
return "", senErr("signPub: 32 байта")
}
if l.TLS && len(l.TLSPin) != 32 {
return "", senErr("tlsPin: 32 байта при TLS")
}
if len(l.Name) > 64 {
return "", senErr("Имя: до 64 байт")
}
var flags byte
if l.TLS {
flags = flagTLS
}
body := []byte{senVersion, flags, byte(len(l.Addrs))}
for _, a := range l.Addrs {
b, err := encodeAddr(a)
if err != nil {
return "", err
}
body = append(body, b...)
}
body = append(body, l.Secret...)
body = append(body, l.SignPub...)
if l.TLS {
body = append(body, l.TLSPin...)
}
body = append(body, byte(len(l.Name)))
body = append(body, l.Name...)
body = binary.BigEndian.AppendUint32(body, crc32.ChecksumIEEE(body))
return senPrefix + b64url.EncodeToString(body), nil
}
var (
reB64urlChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
reSpaces = regexp.MustCompile(`\s+`)
)
func DecodeSenLink(link string) (SenLink, error) {
var l SenLink
t := strings.TrimSpace(link)
if !strings.HasPrefix(strings.ToLower(t), senPrefix) {
return l, senErr("Ссылка должна начинаться с sen://")
}
payload := reSpaces.ReplaceAllString(t[len(senPrefix):], "")
if !reB64urlChars.MatchString(payload) {
return l, senErr("Ссылка повреждена: недопустимые символы")
}
raw := fromB64url(payload)
if len(raw) < 3+4 {
return l, senErr("Ссылка слишком короткая")
}
body := raw[:len(raw)-4]
if binary.BigEndian.Uint32(raw[len(raw)-4:]) != crc32.ChecksumIEEE(body) {
return l, senErr("Ссылка повреждена: не сошлась контрольная сумма")
}
off := 0
errCut := senErr("Ссылка обрезана")
take := func(n int) ([]byte, error) {
if off+n > len(body) {
return nil, errCut
}
s := body[off : off+n]
off += n
return s, nil
}
b1 := func() (byte, error) {
b, err := take(1)
if err != nil {
return 0, err
}
return b[0], nil
}
version, err := b1()
if err != nil {
return l, err
}
if version != senVersion {
return l, senErr("Неизвестная версия ссылки: %d", version)
}
flags, err := b1()
if err != nil {
return l, err
}
if flags&^flagTLS != 0 {
return l, senErr("Неизвестные флаги ссылки")
}
n, err := b1()
if err != nil {
return l, err
}
if n < 1 || n > 3 {
return l, senErr("Неверное число адресов")
}
for range n {
typ, err := b1()
if err != nil {
return l, err
}
var host string
switch typ {
case addrV4:
b, err := take(4)
if err != nil {
return l, err
}
host = fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
case addrV6:
b, err := take(16)
if err != nil {
return l, err
}
host = bytesToIPv6(b)
case addrDomain:
ln, err := b1()
if err != nil {
return l, err
}
if ln < 1 {
return l, senErr("Пустой домен")
}
b, err := take(int(ln))
if err != nil {
return l, err
}
host = jsUTF8(b)
if !reDomain.MatchString(host) {
return l, senErr("Неверный домен")
}
default:
return l, senErr("Неизвестный тип адреса: %d", typ)
}
pb, err := take(2)
if err != nil {
return l, err
}
port := int(binary.BigEndian.Uint16(pb))
if port == 0 {
return l, senErr("Порт 0")
}
l.Addrs = append(l.Addrs, SenAddr{host, port})
}
if l.Secret, err = take(16); err != nil {
return l, err
}
if l.SignPub, err = take(32); err != nil {
return l, err
}
l.TLS = flags&flagTLS != 0
if l.TLS {
if l.TLSPin, err = take(32); err != nil {
return l, err
}
}
nameLen, err := b1()
if err != nil {
return l, err
}
if nameLen > 64 {
return l, senErr("Имя длиннее 64 байт")
}
nb, err := take(int(nameLen))
if err != nil {
return l, err
}
l.Name = jsUTF8(nb)
if off != len(body) {
return l, senErr("Лишние данные в конце ссылки")
}
l.Secret, l.SignPub = bytes.Clone(l.Secret), bytes.Clone(l.SignPub)
l.TLSPin = bytes.Clone(l.TLSPin)
return l, nil
}
// jsUTF8 — Buffer.toString("utf8"): битые последовательности заменяются на U+FFFD.
func jsUTF8(b []byte) string {
if utf8.Valid(b) {
return string(b)
}
return strings.ToValidUTF8(string(b), "�")
}
// ── Ключи Ed25519 ──────────────────────────────────────────────────────────
// loadEd25519Private читает PKCS#8 PEM (как crypto.createPrivateKey в Node).
func loadEd25519Private(raw []byte) (ed25519.PrivateKey, error) {
blk, _ := pem.Decode(raw)
if blk == nil {
return nil, errors.New("not a PEM key")
}
k, err := x509.ParsePKCS8PrivateKey(blk.Bytes)
if err != nil {
return nil, err
}
priv, ok := k.(ed25519.PrivateKey)
if !ok {
return nil, errors.New("not an Ed25519 key")
}
return priv, nil
}
// spkiPin — SHA-256 от SPKI DER сертификата (первого в PEM) — то, что кладётся в ссылку как pin.
func spkiPin(certPEM []byte) ([]byte, error) {
blk, _ := pem.Decode(certPEM)
if blk == nil {
return nil, errors.New("not a PEM certificate")
}
c, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return nil, err
}
sum := sha256.Sum256(c.RawSubjectPublicKeyInfo)
return sum[:], nil
}
// ── Подписи протокола ──────────────────────────────────────────────────────
// signedResponse — {"body": "<JSON-строка>", "sig": "<base64url>"}; sig — Ed25519 над
// UTF-8 байтами строки body. body — именно строка, чтобы клиенту не нужна была
// каноническая сериализация JSON.
func signResponse(payload any, key ed25519.PrivateKey) jsjson.Object {
body := string(jsjson.MustMarshal(payload))
return jsjson.Object{{Key: "body", Val: body}, {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, []byte(body)))}}
}
// requestSigString — METHOD \n /path \n ts \n hex(sha256(тело запроса)); path — без query.
func requestSigString(method, path string, ts int64, body []byte) string {
h := sha256.Sum256(body)
return strings.ToUpper(method) + "\n" + path + "\n" + strconv.FormatInt(ts, 10) + "\n" + hex.EncodeToString(h[:])
}
func verifyRequest(method, path string, ts int64, body []byte, sig string, authPub []byte) bool {
if len(authPub) != ed25519.PublicKeySize {
return false
}
return ed25519.Verify(authPub, []byte(requestSigString(method, path, ts, body)), fromB64url(sig))
}
func crc32IEEE(b []byte) uint32 { return crc32.ChecksumIEEE(b) }
+142
View File
@@ -0,0 +1,142 @@
package ui
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"os"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Эталон — вывод самого awg-ui/sen.ts (tools/golden/sen.ts).
type senFixture struct {
SignSeed string `json:"sign_seed"`
SignPub string `json:"sign_pub"`
CRC32 []struct {
In string `json:"in"`
Out uint32 `json:"out"`
} `json:"crc32"`
Encode []struct {
TLS bool `json:"tls"`
Addrs []SenAddr `json:"addrs"`
Secret string `json:"secret"`
TLSPin string `json:"tlsPin"`
Name string `json:"name"`
Out string `json:"out"`
Error string `json:"error"`
} `json:"encode"`
EncodeErrors []struct {
TLS bool `json:"tls"`
Addrs []SenAddr `json:"addrs"`
Secret string `json:"secret"`
Name string `json:"name"`
Error string `json:"error"`
} `json:"encode_errors"`
Decode []struct {
In string `json:"in"`
Error string `json:"error"`
TLS bool `json:"tls"`
Addrs []SenAddr `json:"addrs"`
Secret string `json:"secret"`
SignPub string `json:"signPub"`
TLSPin *string `json:"tlsPin"`
Name string `json:"name"`
} `json:"decode"`
SigString []struct {
Method, Path, Body, Out, Sig string
TS int64 `json:"ts"`
} `json:"sig_string"`
SignResponse []struct {
In json.RawMessage `json:"in"`
Out struct{ Body, Sig string }
} `json:"sign_response"`
}
func unhex(t *testing.T, s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
t.Fatal(err)
}
return b
}
func TestSenAgainstNode(t *testing.T) {
raw, err := os.ReadFile("../../testdata/golden/sen.json")
if err != nil {
t.Fatal(err)
}
var f senFixture
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
key := ed25519.NewKeyFromSeed(unhex(t, f.SignSeed))
signPub := key.Public().(ed25519.PublicKey)
if hex.EncodeToString(signPub) != f.SignPub {
t.Fatal("sign key mismatch")
}
for _, c := range f.CRC32 {
if got := crc32IEEE([]byte(c.In)); got != c.Out {
t.Errorf("crc32(%q) = %d, want %d", c.In, got, c.Out)
}
}
for _, c := range f.Encode {
l := SenLink{TLS: c.TLS, Addrs: c.Addrs, Secret: unhex(t, c.Secret), SignPub: signPub, Name: c.Name}
if c.TLSPin != "" {
l.TLSPin = unhex(t, c.TLSPin)
}
got, err := EncodeSenLink(l)
if err != nil || got != c.Out {
t.Errorf("encode %+v:\n got %s %v\nwant %s", c.Addrs, got, err, c.Out)
}
}
for _, c := range f.EncodeErrors {
_, err := EncodeSenLink(SenLink{TLS: c.TLS, Addrs: c.Addrs, Secret: unhex(t, c.Secret), SignPub: signPub, Name: c.Name})
if err == nil || err.Error() != c.Error {
t.Errorf("encode error %+v: got %v, want %s", c.Addrs, err, c.Error)
}
}
for _, c := range f.Decode {
l, err := DecodeSenLink(c.In)
if c.Error != "" {
if err == nil || err.Error() != c.Error {
t.Errorf("decode %q: got %v, want error %s", c.In, err, c.Error)
}
continue
}
if err != nil {
t.Errorf("decode %q: %v", c.In, err)
continue
}
gotAddrs, _ := json.Marshal(l.Addrs)
wantAddrs, _ := json.Marshal(c.Addrs)
pin := ""
if c.TLSPin != nil {
pin = *c.TLSPin
}
if l.TLS != c.TLS || string(gotAddrs) != string(wantAddrs) || hex.EncodeToString(l.Secret) != c.Secret ||
hex.EncodeToString(l.SignPub) != c.SignPub || hex.EncodeToString(l.TLSPin) != pin || l.Name != c.Name {
t.Errorf("decode %q:\n got %+v %s", c.In, l, gotAddrs)
}
}
for _, c := range f.SigString {
if got := requestSigString(c.Method, c.Path, c.TS, []byte(c.Body)); got != c.Out {
t.Errorf("sig string: got %q want %q", got, c.Out)
}
if !verifyRequest(c.Method, c.Path, c.TS, []byte(c.Body), c.Sig, signPub) {
t.Errorf("Node signature for %s %s does not verify", c.Method, c.Path)
}
if verifyRequest(c.Method, c.Path, c.TS+1, []byte(c.Body), c.Sig, signPub) {
t.Error("signature verifies for another ts")
}
}
for _, c := range f.SignResponse {
v, _ := jsjson.Decode(c.In)
got := signResponse(v, key)
if got.Get2("body") != c.Out.Body || got.Get2("sig") != c.Out.Sig {
t.Errorf("signResponse: got %v want %+v", got, c.Out)
}
}
}
+954
View File
@@ -0,0 +1,954 @@
// Package ui — awg-ui на Go (порт awg-ui/server.ts и модулей): веб-панель с JWT, прокси к
// awg-ctrl, публичный API /api/v1 по ключам, хаб нод (WSS), подписка sen:// и /metrics.
// Фронтенд тот же (React, собранный в dist/) — сервер его только раздаёт.
package ui
import (
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
// ── Журнал: как console.log / console.warn у Node-версии (CLI пишет оба потока в один файл) ──
func logf(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
func warnf(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
var nowFn = time.Now
func now() int64 { return nowFn().Unix() }
func envOr(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
// envNum — Number(process.env.X) || 0.
func envNum(key string) int {
f := jsToNumber(os.Getenv(key))
if f != f || f == 0 || f != float64(int(f)) {
return 0
}
return int(f)
}
// envFromFile — значение KEY=… из .env (первая строка, обрезанная).
func envFromFile(file, key string) string {
raw, err := os.ReadFile(file)
if err != nil {
return ""
}
m := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(.*)$`).FindSubmatch(raw)
if m == nil {
return ""
}
return strings.TrimSpace(string(m[1]))
}
// ── Клиент awg-ctrl ─────────────────────────────────────────────────────────
type CtrlResp struct {
Status int
Data any // разобранный JSON, иначе строка (как axios)
}
// Ctrl — запрос к awg-ctrl сервера: локальному по HTTP или к ноде по WSS. Ошибка — только
// когда ответа нет вовсе (сеть, нет ключа); статусы ≥ 400 приходят как есть.
type Ctrl func(method, path string, body any) (*CtrlResp, error)
// decodeLikeAxios — тело ответа так, как его отдал бы axios: JSON — значением, иначе строкой.
func decodeLikeAxios(raw []byte) any {
if len(raw) == 0 {
return ""
}
if v, err := jsjson.Decode(raw); err == nil {
return v
}
return string(raw)
}
type App struct {
port int
uiUser string
uiPass string
jwtSecret []byte
brand string
channel string
version string
ctrlURL string
baseDir string
internalKey ed25519.PrivateKey
httpLocal *http.Client
db *sql.DB
mu sync.Mutex
revoked map[string]bool
loginAttempts map[string]*rateEntry
nodes *Nodes
sub *Sub
metrics *Metrics
monitor *Monitor
migration *Migration
}
// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса
// (CLI запускает сервис с cwd = <root>/awg-ui): там dist/, panel.id и ключи по умолчанию.
func New() (*App, error) {
cwd, _ := os.Getwd()
rootEnv := filepath.Join(cwd, "..", ".env")
pick := func(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
if v := envFromFile(rootEnv, key); v != "" {
return v
}
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,
// awg-ctrl на loopback: системный прокси сюда не нужен.
httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}},
revoked: map[string]bool{},
loginAttempts: map[string]*rateEntry{},
}
if a.port == 0 {
a.port = 8080
}
// Как `process.env.JWT_SECRET ?? random` в Node: заданный (даже пустой) секрет берётся как есть.
if s, ok := os.LookupEnv("JWT_SECRET"); ok {
a.jwtSecret = []byte(s)
} else {
b := make([]byte, 32)
rand.Read(b)
a.jwtSecret = []byte(hex.EncodeToString(b))
}
keyFile := envOr("INTERNAL_AUTH_KEY_FILE", filepath.Join(cwd, "internal_auth_private.key"))
if k, err := iauth.LoadPrivate(keyFile); err == nil {
a.internalKey = k
} else {
warnf("ui: приватный ключ внутренней авторизации не найден: %s — запросы к awg-ctrl будут отклоняться", keyFile)
}
dbFile := os.Getenv("UI_DB_FILE")
if dbFile == "" {
dbFile = filepath.Join(cwd, "ui.db")
}
db, err := openUIDB(dbFile)
if err != nil {
return nil, err
}
a.db = db
if err := a.initAPIKeys(); err != nil {
return nil, err
}
if a.nodes, err = newNodes(a); err != nil {
return nil, err
}
if a.sub, err = newSub(a); err != nil {
return nil, err
}
if a.metrics, err = newMetrics(a); err != nil {
return nil, err
}
a.monitor = newMonitor(a)
a.migration = newMigration(a)
a.nodes.BeforeDelete(a.apiKeysBeforeDelete)
a.nodes.AfterDelete(a.apiKeysAfterDelete)
return a, nil
}
func openUIDB(path string) (*sql.DB, error) {
dsn := "file:" + (&url.URL{Path: path}).EscapedPath() +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// Одно соединение: запросы и транзакции идут по очереди, как в однопоточном Node.
// Внутри tx() нельзя обращаться к a.db — только к переданному tx, иначе взаимоблокировка.
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// tx — uidb.transaction(fn)(): всё или ничего.
func (a *App) tx(fn func(tx *sql.Tx) error) error {
t, err := a.db.Begin()
if err != nil {
return err
}
if err := fn(t); err != nil {
t.Rollback()
return err
}
return t.Commit()
}
// ctrlLocal — запрос к awg-ctrl этой машины с внутренним токеном.
func (a *App) ctrlLocal(method, path string, body any) (*CtrlResp, error) {
if a.internalKey == nil {
return nil, fmt.Errorf("приватный ключ внутренней авторизации не загружен")
}
var rd *strings.Reader
if body != nil {
b, err := jsjson.Marshal(body)
if err != nil {
return nil, err
}
rd = strings.NewReader(string(b))
}
var req *http.Request
var err error
if rd != nil {
req, err = http.NewRequest(method, a.ctrlURL+path, rd)
} else {
req, err = http.NewRequest(method, a.ctrlURL+path, nil)
}
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-ui", nowFn()))
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpLocal.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := readAll(resp)
if err != nil {
return nil, err
}
return &CtrlResp{resp.StatusCode, decodeLikeAxios(raw)}, nil
}
// ── JWT панели (HS256) ──────────────────────────────────────────────────────
func tokenSig(token string) string {
if p := strings.Split(token, "."); len(p) > 2 {
return p[2]
}
return token
}
func (a *App) sign() string {
h := b64url.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
p := b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{{Key: "exp", Val: now() + 86400}}))
m := hmac.New(sha256.New, a.jwtSecret)
m.Write([]byte(h + "." + p))
return h + "." + p + "." + b64url.EncodeToString(m.Sum(nil))
}
func (a *App) verify(token string) bool {
a.mu.Lock()
rev := a.revoked[tokenSig(token)]
a.mu.Unlock()
if rev {
return false
}
parts := strings.Split(token, ".")
if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return false
}
m := hmac.New(sha256.New, a.jwtSecret)
m.Write([]byte(parts[0] + "." + parts[1]))
if !hmac.Equal(fromB64url(parts[2]), m.Sum(nil)) {
return false
}
v, err := jsjson.Decode(fromB64url(parts[1]))
if err != nil {
return false
}
exp, ok := field(v, "exp").(float64)
return ok && exp > float64(now())
}
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return h[7:]
}
return ""
}
func (a *App) requireAuth(c *Ctx) bool {
tok := bearer(c.R)
if tok == "" || !a.verify(tok) {
return c.Error(401, "Unauthorized")
}
return true
}
// ── Лимит входа ─────────────────────────────────────────────────────────────
const (
rateLimit = 5
rateWindow = 15 * time.Minute
)
type rateEntry struct {
count int
resetAt time.Time
}
func (a *App) checkRate(ip string) (bool, int) {
a.mu.Lock()
defer a.mu.Unlock()
t := nowFn()
e := a.loginAttempts[ip]
if e == nil || e.resetAt.Before(t) {
a.loginAttempts[ip] = &rateEntry{1, t.Add(rateWindow)}
return true, 0
}
if e.count >= rateLimit {
ms := e.resetAt.Sub(t).Milliseconds()
return false, int((ms + 999) / 1000)
}
e.count++
return true, 0
}
// ── Ключи внешнего API (/api/v1) ────────────────────────────────────────────
// Живут в собственной БД awg-ui: awg-ctrl про них ничего не знает. Открытое значение
// ключа не хранится — только SHA-256 и префикс для показа в панели.
type apiKeyRow struct {
ID int64
Label string
KeyHash string
Prefix string
ServerID int
CreatedAt int64
LastUsed sql.NullInt64
}
func (a *App) initAPIKeys() error {
_, err := a.db.Exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_used INTEGER
);
-- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах
-- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users,
-- всегда входит в набор.
CREATE TABLE IF NOT EXISTS api_key_servers (
key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
PRIMARY KEY (key_id, server_id)
);
-- Ключи со времён одного сервера: набор — их server_id.
INSERT OR IGNORE INTO api_key_servers (key_id, server_id)
SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers);
`)
return err
}
func scanKey(row interface{ Scan(...any) error }) (*apiKeyRow, error) {
k := &apiKeyRow{}
if err := row.Scan(&k.ID, &k.Label, &k.KeyHash, &k.Prefix, &k.ServerID, &k.CreatedAt, &k.LastUsed); err != nil {
return nil, err
}
return k, nil
}
const keyCols = "id, label, key_hash, prefix, server_id, created_at, last_used"
func (a *App) keyByID(q queryer, id int64) *apiKeyRow {
k, _ := scanKey(q.QueryRow("SELECT "+keyCols+" FROM api_keys WHERE id = ?", id))
return k
}
type queryer interface {
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
Exec(query string, args ...any) (sql.Result, error)
}
// ints — первая колонка всех строк.
func ints(q queryer, query string, args ...any) []int {
rows, err := q.Query(query, args...)
if err != nil {
return nil
}
defer rows.Close()
out := []int{}
for rows.Next() {
var n int
if rows.Scan(&n) == nil {
out = append(out, n)
}
}
return out
}
func (a *App) keyServers(q queryer, id int64) []int {
return ints(q, "SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id", id)
}
// setKeyServers — набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в
// наборе, иначе — наименьший из набора.
func (a *App) setKeyServers(id int64, ids []int) error {
row := a.keyByID(a.db, id)
if row == nil {
return nil
}
return a.tx(func(tx *sql.Tx) error {
if _, err := tx.Exec("DELETE FROM api_key_servers WHERE key_id = ?", id); err != nil {
return err
}
for _, sid := range ids {
if _, err := tx.Exec("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)", id, sid); err != nil {
return err
}
}
if !containsInt(ids, row.ServerID) {
_, err := tx.Exec("UPDATE api_keys SET server_id = ? WHERE id = ?", ids[0], id)
return err
}
return nil
})
}
func containsInt(s []int, v int) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// uniqueInts — [...new Set(v)] для массива из JSON: все элементы — целые серверы из реестра.
// Иначе — ok=false.
func (a *App) serverList(v any, exists func(int) bool) ([]int, bool) {
list := arr(v)
seen := map[float64]bool{}
var ids []int
for _, x := range list {
f, isNum := x.(float64)
if !isNum || !isInt(x) || !exists(int(f)) {
return nil, false
}
if !seen[f] {
seen[f] = true
ids = append(ids, int(f))
}
}
sort.Ints(ids)
return ids, true
}
// parseKeyServers — набор серверов из тела: непустой, без повторов, только существующие.
func (a *App) parseKeyServers(v any) ([]int, string) {
if l, ok := v.([]any); !ok || len(l) == 0 {
return nil, "Нужен хотя бы один сервер"
}
ids, ok := a.serverList(v, a.nodes.Exists)
if !ok {
return nil, "Такого сервера нет"
}
return ids, ""
}
func hashKey(key string) string {
s := sha256.Sum256([]byte(key))
return hex.EncodeToString(s[:])
}
func genAPIKey() (key, hash, prefix string) {
b := make([]byte, 24)
rand.Read(b)
key = "awgk_" + b64url.EncodeToString(b)
return key, hashKey(key), key[:13] + "…"
}
var reKeyLabel = regexp.MustCompile(`^[\w \-]{1,40}$`)
func intsJSON(s []int) []any {
out := make([]any, len(s))
for i, v := range s {
out[i] = v
}
return out
}
func nullInt(n sql.NullInt64) any {
if n.Valid {
return n.Int64
}
return nil
}
func (a *App) apiKeysList(c *Ctx) bool {
rows, err := a.db.Query("SELECT " + keyCols + " FROM api_keys ORDER BY id")
if err != nil {
return c.Error(500, "Internal server error")
}
var keys []*apiKeyRow
for rows.Next() {
if k, err := scanKey(rows); err == nil {
keys = append(keys, k)
}
}
rows.Close()
out := []any{}
for _, k := range keys {
out = append(out, jsjson.Object{
{Key: "id", Val: k.ID}, {Key: "label", Val: k.Label}, {Key: "prefix", Val: k.Prefix},
{Key: "server_id", Val: k.ServerID}, {Key: "created_at", Val: k.CreatedAt}, {Key: "last_used", Val: nullInt(k.LastUsed)},
{Key: "servers", Val: intsJSON(a.keyServers(a.db, k.ID))},
})
}
return c.JSON(200, jsjson.Object{{Key: "keys", Val: out}})
}
// jsString — String(v) для меток: строка, число или логическое значение. Остальное — не метка.
func jsString(v any) (string, bool) {
switch x := v.(type) {
case string:
return x, true
case float64:
return jsjson.Number(x), true
case bool:
return strconv.FormatBool(x), true
}
return "", false
}
// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию.
func (a *App) apiKeysCreate(c *Ctx) bool {
b := c.Obj()
label, labelOK := jsString(b.Get2("label"))
if !truthy(b.Get2("label")) || !labelOK || !reKeyLabel.MatchString(label) {
return c.Error(400, "Метка: буквы, цифры, пробел, _ и -, до 40 символов")
}
// server_id === undefined в JSON — только отсутствие ключа; null — это Number(null) = 0.
sidRaw, sidPresent := b.Get("server_id")
var serversV any
if s := b.Get2("servers"); s != nil {
serversV = s
} else if !sidPresent {
serversV = []any{float64(a.nodes.DefaultID())}
} else {
serversV = []any{jsToNumberAny(sidRaw)}
}
ids, msg := a.parseKeyServers(serversV)
if msg != "" {
return c.Error(400, msg)
}
sid := ids[0]
if sidPresent {
if f := jsToNumberAny(sidRaw); f == float64(int(f)) && containsInt(ids, int(f)) {
sid = int(f)
}
}
key, hash, prefix := genAPIKey()
var id int64
err := a.tx(func(tx *sql.Tx) error {
r, err := tx.Exec("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)",
label, hash, prefix, sid, now())
if err != nil {
return err
}
id, _ = r.LastInsertId()
for _, s := range ids {
if _, err := tx.Exec("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)", id, s); err != nil {
return err
}
}
return nil
})
if err != nil {
return c.Error(500, "Internal server error")
}
// Открытое значение отдаётся один раз — дальше в БД только хэш.
return c.JSON(201, jsjson.Object{{Key: "id", Val: id}, {Key: "label", Val: b.Get2("label")}, {Key: "prefix", Val: prefix},
{Key: "key", Val: key}, {Key: "server_id", Val: sid}, {Key: "servers", Val: intsJSON(ids)}})
}
// paramID — Number(req.params.id): ok, если целое.
func paramID(s string) (int64, bool) {
f := jsToNumber(s)
if f != f || f != float64(int64(f)) {
return 0, false
}
return int64(f), true
}
func (a *App) apiKeysPatch(c *Ctx) bool {
id, ok := paramID(c.Params["id"])
if !ok || a.keyByID(a.db, id) == nil {
return c.Error(404, "Не найден")
}
ids, msg := a.parseKeyServers(c.Obj().Get2("servers"))
if msg != "" {
return c.Error(400, msg)
}
if err := a.setKeyServers(id, ids); err != nil {
return c.Error(500, "Internal server error")
}
row := a.keyByID(a.db, id)
return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "server_id", Val: row.ServerID}, {Key: "servers", Val: intsJSON(ids)}})
}
func (a *App) apiKeysDelete(c *Ctx) bool {
id, ok := paramID(c.Params["id"])
if !ok {
return c.Error(400, "Неверный id")
}
a.tx(func(tx *sql.Tx) error {
tx.Exec("DELETE FROM api_key_servers WHERE key_id = ?", id)
_, err := tx.Exec("DELETE FROM api_keys WHERE id = ?", id)
return err
})
return c.JSON(200, jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: id}})
}
// Сервер удаляют из реестра: ключ API, для которого он единственный, — повод отказать
// (ключ остался бы без серверов); у остальных он убирается из набора, когда сервер удалён.
type keyOnServer struct {
id int64
label string
serverID int
n int
}
func (a *App) keysOnServer(id int) []keyOnServer {
rows, err := a.db.Query("SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?", id)
if err != nil {
return nil
}
defer rows.Close()
var out []keyOnServer
for rows.Next() {
var k keyOnServer
if rows.Scan(&k.id, &k.label, &k.serverID, &k.n) == nil {
out = append(out, k)
}
}
return out
}
func (a *App) apiKeysBeforeDelete(id int, _ bool) *deleteVeto {
var only []string
for _, k := range a.keysOnServer(id) {
if k.n == 1 {
only = append(only, k.label)
}
}
if len(only) > 0 {
return &deleteVeto{Error: "Это единственный сервер ключей API: " + strings.Join(only, ", ") + " — сначала удалите их или добавьте им другой сервер"}
}
return nil
}
func (a *App) apiKeysAfterDelete(id int) {
for _, k := range a.keysOnServer(id) {
var rest []int
for _, s := range a.keyServers(a.db, k.id) {
if s != id {
rest = append(rest, s)
}
}
a.setKeyServers(k.id, rest)
}
}
// ── Внешний API: ключ и сервер ──────────────────────────────────────────────
func (a *App) requireAPIKey(c *Ctx) bool {
key := c.R.Header.Get("X-Api-Key")
if !strings.HasPrefix(key, "awgk_") {
return c.Error(401, "API key required")
}
row, err := scanKey(a.db.QueryRow("SELECT "+keyCols+" FROM api_keys WHERE key_hash = ?", hashKey(key)))
if err != nil {
return c.Error(401, "Invalid API key")
}
a.db.Exec("UPDATE api_keys SET last_used = ? WHERE id = ?", now(), row.ID)
c.apiKey = row
c.servers = a.keyServers(a.db, row.ID)
return true
}
// pickKeyServer — сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка —
// сервер ключа по умолчанию. Сервер вне набора — как несуществующий.
func (a *App) pickKeyServer(c *Ctx) bool {
id := c.apiKey.ServerID
if raw, ok := c.R.Header["X-Server-Id"]; ok {
f := jsToNumber(strings.Join(raw, ", "))
if f != float64(int(f)) {
return c.Error(404, "Сервер не найден")
}
id = int(f)
}
if !containsInt(c.servers, id) {
return c.Error(404, "Сервер не найден")
}
c.serverID = id
return true
}
// serverIDOf — сервер запроса панели: X-Server-Id (0 — локальный awg-ctrl), без него — по умолчанию.
func (a *App) serverIDOf(c *Ctx) int {
raw, ok := c.R.Header["X-Server-Id"]
if !ok {
return a.nodes.DefaultID()
}
f := jsToNumber(strings.Join(raw, ", "))
if f == float64(int(f)) && f >= 0 {
return int(f)
}
return a.nodes.DefaultID()
}
func (a *App) proxy(c *Ctx) bool {
r, err := a.nodes.CtrlFor(a.serverIDOf(c))(c.R.Method, c.R.RequestURI, c.Body)
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
return c.JSON(r.Status, r.Data)
}
// extUser — внешний контракт: наружу только name/ip/gen/vpn_key, psk_key и pub_key остаются внутри.
func extUser(d any) jsjson.Object {
o := jsjson.Object{}
for _, k := range [][2]string{{"name", "name"}, {"ip", "ip"}, {"gen", "key_gen"}, {"vpn_key", "vpn_key"}} {
if hasField(d, k[1]) { // undefined JSON.stringify опускает
o = append(o, jsjson.KV{Key: k[0], Val: field(d, k[1])})
}
}
return o
}
func hasField(v any, k string) bool {
o, ok := v.(jsjson.Object)
return ok && has(o, k)
}
func (a *App) extCtrl(c *Ctx) Ctrl { return a.nodes.CtrlFor(c.serverID) }
// unavailable — ответ, когда awg-ctrl не ответил (как wrap() в Node-версии).
func unavailable(c *Ctx) bool { return c.Error(502, "awg-ctrl недоступен") }
func (a *App) extUsersCreate(c *Ctx) bool {
body := jsjson.Object{}
if v, ok := c.Obj().Get("name"); ok {
body = append(body, jsjson.KV{Key: "name", Val: v})
}
r, err := a.extCtrl(c)("POST", "/api/users", body)
if err != nil {
return unavailable(c)
}
if r.Status >= 400 {
return c.JSON(r.Status, r.Data)
}
return c.JSON(201, extUser(r.Data))
}
func (a *App) extUsersList(c *Ctx) bool {
r, err := a.extCtrl(c)("GET", "/api/users/stats", nil)
if err != nil {
return unavailable(c)
}
return c.JSON(r.Status, r.Data)
}
func (a *App) extUsersGet(c *Ctx) bool {
r, err := a.extCtrl(c)("POST", "/api/users/"+encodeURIComponent(c.Params["name"]), nil)
if err != nil {
return unavailable(c)
}
if r.Status >= 400 {
return c.JSON(r.Status, r.Data)
}
return c.JSON(200, extUser(r.Data))
}
func (a *App) extUsersDelete(c *Ctx) bool {
r, err := a.extCtrl(c)("DELETE", "/api/users/"+encodeURIComponent(c.Params["name"]), nil)
if err != nil {
return unavailable(c)
}
return c.JSON(r.Status, r.Data)
}
// ── Маршруты ────────────────────────────────────────────────────────────────
func (a *App) login(c *Ctx) bool {
ip := c.remoteIP()
if ok, retry := a.checkRate(ip); !ok {
return c.Error(429, fmt.Sprintf("Слишком много попыток. Повтори через %d сек.", retry))
}
b := c.Obj()
user, uok := b.Get2("user").(string)
pass, pok := b.Get2("pass").(string)
if a.uiPass == "" || !uok || !pok || user != a.uiUser || pass != a.uiPass {
return c.Error(401, "Неверный логин или пароль")
}
a.mu.Lock()
delete(a.loginAttempts, ip)
a.mu.Unlock()
return c.JSON(200, jsjson.Object{{Key: "token", Val: a.sign()}})
}
func (a *App) logout(c *Ctx) bool {
a.mu.Lock()
a.revoked[tokenSig(bearer(c.R))] = true
a.mu.Unlock()
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}})
}
func (a *App) routes() *Router {
app := newRouter()
auth := a.requireAuth
app.Post("/login", a.login)
app.Post("/logout", auth, a.logout)
// Единственный роут без авторизации: вордмарк нужен уже на экране логина.
app.Get("/ui/brand", 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)
app.Post("/ui/apikeys", auth, a.apiKeysCreate)
app.Patch("/ui/apikeys/:id", auth, a.apiKeysPatch)
app.Delete("/ui/apikeys/:id", auth, a.apiKeysDelete)
// ВАЖНО: /api/v1 — до JWT-прокси на /api, иначе прокси потребует JWT вместо ключа.
app.Use("/api/v1", a.requireAPIKey)
app.Use("/api/v1/users", a.pickKeyServer)
app.Post("/api/v1/users", a.extUsersCreate)
app.Get("/api/v1/users", a.extUsersList)
app.Get("/api/v1/users/:name", a.extUsersGet)
app.Delete("/api/v1/users/:name", a.extUsersDelete)
app.Mount("/api/v1/masterkeys", a.sub.apiRouter(func(c *Ctx) []int { return c.servers }))
app.Use("/ui/nodes", auth)
app.Mount("/ui/nodes", a.nodes.router())
app.Use("/ui/masterkeys", auth)
app.Mount("/ui/masterkeys", a.sub.masterKeysRouter())
app.Use("/ui/devices", auth)
app.Mount("/ui/devices", a.sub.devicesRouter())
// Prometheus: /metrics за отдельным токеном «только метрики» (не JWT и не ключ API).
app.Use("/ui/metrics-token", auth)
app.Mount("/ui/metrics-token", a.metrics.router())
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
app.Use("/ui/monitor", auth)
app.Mount("/ui/monitor", a.monitor.router())
// Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса.
app.Post("/migrate/v1/import", a.migration.importHandler)
app.Use("/ui/migrate", auth)
app.Mount("/ui/migrate", a.migration.router())
for _, p := range []string{"/api", "/health", "/awg"} {
app.Use(p, auth, a.proxy)
}
app.Use("/", a.static)
return app
}
// static — express.static(dist) и SPA-фолбэк app.get("*") → dist/index.html.
func (a *App) static(c *Ctx) bool {
if c.R.Method != "GET" && c.R.Method != "HEAD" {
return true
}
dist := filepath.Join(a.baseDir, "dist")
p, err := url.PathUnescape(c.R.URL.EscapedPath())
if err == nil && !strings.Contains(p, "\x00") {
clean := filepath.Clean("/" + p)
hidden := false
for _, s := range strings.Split(clean, "/") {
if strings.HasPrefix(s, ".") {
hidden = true
}
}
f := filepath.Join(dist, clean)
if st, err := os.Stat(f); err == nil && !hidden {
if st.IsDir() {
if !strings.HasSuffix(c.R.URL.Path, "/") {
http.Redirect(c.W, c.R, c.R.URL.Path+"/", http.StatusMovedPermanently)
return false
}
f = filepath.Join(f, "index.html")
}
if st, err := os.Stat(f); err == nil && !st.IsDir() {
serveFile(c.W, c.R, f)
return false
}
}
}
index := filepath.Join(dist, "index.html")
if _, err := os.Stat(index); err != nil {
writeHTMLError(c.W, 404, "Error: ENOENT: no such file or directory, stat '"+index+"'")
return false
}
serveFile(c.W, c.R, index)
return false
}
func serveFile(w http.ResponseWriter, r *http.Request, f string) {
fh, err := os.Open(f)
if err != nil {
writeHTMLError(w, 404, "Not Found")
return
}
defer fh.Close()
st, _ := fh.Stat()
w.Header().Set("Cache-Control", "public, max-age=0")
http.ServeContent(w, r, st.Name(), st.ModTime(), fh)
}
// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку).
func (a *App) Run() error {
ln, err := listen(a.port)
if err != nil {
return err
}
srv := &http.Server{Handler: a.routes(), ReadHeaderTimeout: 30 * time.Second}
logf("ui: listening on :%d", a.port)
a.nodes.Start()
a.sub.Start()
return srv.Serve(ln)
}
+1280
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
package ui
import (
"errors"
"path/filepath"
"testing"
)
// Ключ, который есть, но выходит за область API-ключа, — 403, а не 404: клиент не должен
// принимать его за удалённый и выдавать вместо него второй.
func TestMasterForScope(t *testing.T) {
dir := t.TempDir()
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := &Sub{a: &App{db: db, baseDir: dir}} // baseDir: initSchema кладёт туда panel.id
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatal(err)
}
}
for id, secret := range map[int]string{1: "a", 2: "b", 3: "c"} {
exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (?, ?, 'k', ?, 1)", id, "u"+secret, []byte(secret))
}
exec("INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (2, 0), (2, 1), (3, 0)")
exec("UPDATE master_keys SET revoked_at = 1 WHERE id = 3")
status := func(id int64, scope []int) int {
_, err := s.masterFor(id, scope)
var ae *apiError
if err == nil {
return 200
}
if !errors.As(err, &ae) {
t.Fatalf("masterFor(%d, %v): %v", id, scope, err)
}
return ae.status
}
for _, c := range []struct {
name string
id int64
scope []int
want int
}{
{"панель видит всё", 2, nil, 200},
{"ключ в области", 1, []int{0}, 200},
{"сервер ключа вне области", 2, []int{0}, 403},
{"нет такого ключа", 99, []int{0}, 404},
{"удалённый ключ", 3, []int{0}, 404},
{"удалённый ключ в панели", 3, nil, 404},
} {
if got := status(c.id, c.scope); got != c.want {
t.Errorf("%s: %d, ожидали %d", c.name, got, c.want)
}
}
}
+313
View File
@@ -0,0 +1,313 @@
package ui
// Минимальный роутер с поведением Express 4, на которое опирается фронтенд и внешние клиенты:
// - тело разбирает express.json() до маршрутизации и для всех путей: без тела или не JSON —
// пустой объект {}, битый JSON / не объект и не массив — HTML 400, больше лимита — HTML 413;
// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется;
// - use(prefix) совпадает по границе сегмента; первое подходящее объявление выигрывает;
// - неизвестный путь — HTML 404 «Cannot METHOD /path», как finalhandler.
import (
"fmt"
"html"
"io"
"mime"
"net"
"net/http"
"net/url"
"strings"
"unicode"
"github.com/maeneko/forgetting/internal/jsjson"
)
type Ctx struct {
W http.ResponseWriter
R *http.Request
Params map[string]string
Body any // jsjson.Object, []any; без тела — пустой Object
RawBody []byte // как пришло (для подписи /sub/v1)
Path string // путь внутри смонтированного use(prefix), как req.path в под-роутере
// Внешний API: ключ запроса, его серверы и выбранный сервер.
apiKey *apiKeyRow
servers []int
serverID int
device *deviceRow
}
// handler возвращает false, если ответ уже отправлен и цепочка дальше не идёт.
type handler func(c *Ctx) bool
type route struct {
method string // "" — любой (use)
prefix bool // use(): совпадение по префиксу
segs []string
hs []handler
}
type Router struct {
routes []route
// notFound — ответ, если ничего не совпало (у /sub/v1 он подписанный).
notFound func(c *Ctx)
// badBody — ответ на неразобранное тело (у /sub/v1 подписанный 400).
badBody func(w http.ResponseWriter, r *http.Request, status int, msg string)
bodyLimit int64
}
func newRouter() *Router { return &Router{bodyLimit: 100 * 1024} }
func splitPath(p string) []string {
p = strings.Trim(p, "/")
if p == "" {
return nil
}
return strings.Split(p, "/")
}
func (rt *Router) add(method string, prefix bool, pattern string, hs ...handler) {
rt.routes = append(rt.routes, route{method: method, prefix: prefix, segs: splitPath(pattern), hs: hs})
}
func (rt *Router) Get(p string, hs ...handler) { rt.add("GET", false, p, hs...) }
func (rt *Router) Post(p string, hs ...handler) { rt.add("POST", false, p, hs...) }
func (rt *Router) Put(p string, hs ...handler) { rt.add("PUT", false, p, hs...) }
func (rt *Router) Patch(p string, hs ...handler) { rt.add("PATCH", false, p, hs...) }
func (rt *Router) Delete(p string, hs ...handler) { rt.add("DELETE", false, p, hs...) }
func (rt *Router) Use(p string, hs ...handler) { rt.add("", true, p, hs...) }
// Mount — app.use(prefix, mw..., router): маршруты под-роутера с префиксом и общими mw.
func (rt *Router) Mount(prefix string, sub *Router, mw ...handler) {
pre := splitPath(prefix)
for _, r := range sub.routes {
segs := append(append([]string{}, pre...), r.segs...)
rt.routes = append(rt.routes, route{method: r.method, prefix: r.prefix, segs: segs, hs: append(append([]handler{}, mw...), r.hs...)})
}
}
// errDecode — %-последовательность в параметре не декодируется (Express: 400 URIError).
type errDecode struct{}
func match(r route, method string, segs []string) (map[string]string, int, error) {
if r.method != "" && r.method != method && !(r.method == "GET" && method == "HEAD") {
return nil, 0, nil
}
if len(segs) < len(r.segs) || !r.prefix && len(segs) != len(r.segs) {
return nil, 0, nil
}
params := map[string]string{}
for i, p := range r.segs {
if strings.HasPrefix(p, ":") {
v, err := url.PathUnescape(segs[i])
if err != nil {
return nil, 0, fmt.Errorf("URIError: Failed to decode param '%s'", segs[i])
}
params[p[1:]] = v
} else if !strings.EqualFold(p, segs[i]) {
return nil, 0, nil
}
}
return params, len(r.segs), nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
b, err := jsjson.Marshal(v)
if err != nil {
status, b = 500, []byte(`{"error":"Internal server error"}`)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(b)
}
func writeHTMLError(w http.ResponseWriter, status int, text string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
fmt.Fprintf(w, "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>%s</pre>\n</body>\n</html>\n", html.EscapeString(text))
}
// readBody — express.json({limit}): только application/json, объект или массив.
func readBody(r *http.Request, limit int64) (body any, raw []byte, status int, msg string) {
empty := jsjson.Object{}
mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mt != "application/json" || r.Body == nil || r.Body == http.NoBody {
return empty, nil, 0, ""
}
if r.ContentLength > limit {
return nil, nil, 413, "PayloadTooLargeError: request entity too large"
}
raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
if err != nil {
return nil, nil, 400, "BadRequestError: request aborted"
}
if int64(len(raw)) > limit {
return nil, nil, 413, "PayloadTooLargeError: request entity too large"
}
trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace)
if trimmed == "" {
return empty, raw, 0, ""
}
if trimmed[0] != '{' && trimmed[0] != '[' {
return nil, nil, 400, "SyntaxError: Unexpected token in JSON"
}
v, err := jsjson.Decode(raw)
if err != nil {
return nil, nil, 400, "SyntaxError: " + err.Error()
}
return v, raw, 0, ""
}
func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, raw, status, msg := readBody(r, rt.bodyLimit)
if status != 0 {
if rt.badBody != nil {
rt.badBody(w, r, status, msg)
} else {
writeHTMLError(w, status, msg)
}
return
}
segs := splitPath(r.URL.EscapedPath())
c := &Ctx{W: w, R: r, Body: body, RawBody: raw}
for _, route := range rt.routes {
params, n, err := match(route, r.Method, segs)
if err != nil {
writeHTMLError(w, 400, err.Error())
return
}
if params == nil {
continue
}
c.Params, c.Path = params, "/"+strings.Join(segs[n:], "/")
for _, h := range route.hs {
if !h(c) {
return // ответ отправлен
}
}
// Все обработчики вызвали next() — ищем дальше, как Express.
}
if rt.notFound != nil {
rt.notFound(c)
return
}
writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.EscapedPath())
}
// ── Ответы ──────────────────────────────────────────────────────────────────
func (c *Ctx) JSON(status int, v any) bool {
writeJSON(c.W, status, v)
return false
}
func errObj(msg string) jsjson.Object { return jsjson.Object{{Key: "error", Val: msg}} }
func (c *Ctx) Error(status int, msg string) bool { return c.JSON(status, errObj(msg)) }
func (c *Ctx) Obj() jsjson.Object {
o, _ := c.Body.(jsjson.Object)
return o
}
// remoteIP — req.socket.remoteAddress.
func (c *Ctx) remoteIP() string {
host, _, err := net.SplitHostPort(c.R.RemoteAddr)
if err != nil {
return "unknown"
}
return host
}
// hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках.
func (c *Ctx) hostname() string {
h := c.R.Host
if strings.HasPrefix(h, "[") {
if i := strings.Index(h, "]"); i >= 0 {
return h[:i+1]
}
return h
}
if i := strings.Index(h, ":"); i >= 0 {
return h[:i]
}
return h
}
// ── Значения из разобранного JSON (как обращение к полям в JS) ──────────────
// field — v.a.b.c; чего нет — nil (undefined).
func field(v any, keys ...string) any {
for _, k := range keys {
o, ok := v.(jsjson.Object)
if !ok {
return nil
}
v = o.Get2(k)
}
return v
}
func has(o jsjson.Object, k string) bool {
_, ok := o.Get(k)
return ok
}
func str(v any) (string, bool) {
s, ok := v.(string)
return s, ok
}
func num(v any) (float64, bool) {
f, ok := v.(float64)
return f, ok
}
// isInt — Number.isInteger.
func isInt(v any) bool {
f, ok := v.(float64)
return ok && f == float64(int64(f)) && f >= -9007199254740991*1024 && f <= 9007199254740991*1024
}
// isSafeInt — Number.isSafeInteger.
func isSafeInt(f float64) bool {
return f == float64(int64(f)) && f >= -9007199254740991 && f <= 9007199254740991
}
// numOr — `x ?? def` для числового поля.
func numOr(v any, def float64) float64 {
if f, ok := v.(float64); ok {
return f
}
if v == nil {
return def
}
return def
}
func strOr(v any, def string) string {
if s, ok := v.(string); ok {
return s
}
return def
}
func truthy(v any) bool {
switch x := v.(type) {
case nil:
return false
case bool:
return x
case float64:
return x != 0 && x == x
case string:
return x != ""
}
return true
}
func arr(v any) []any {
a, _ := v.([]any)
return a
}
+323
View File
@@ -0,0 +1,323 @@
{
"sign_seed": "0b6f395ca14ac202374d5cff678b71157d0bef7e00c3045e9b80aab4ffb85276",
"sign_pub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
"crc32": [
{
"in": "",
"out": 0
},
{
"in": "a",
"out": 3904355907
},
{
"in": "hello",
"out": 907060870
},
{
"in": "кириллица",
"out": 2804376611
}
],
"encode": [
{
"tls": false,
"addrs": [
{
"host": "203.0.113.7",
"port": 8443
}
],
"secret": "e8bc163c82eee18733288c7d4ac636db",
"name": "Семья",
"out": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg"
},
{
"tls": true,
"addrs": [
{
"host": "vpn.example.com",
"port": 443
}
],
"secret": "ad328846aa18b32a335816374511cac1",
"tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812",
"name": "",
"out": "sen://AQEBRA92cG4uZXhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk"
},
{
"tls": false,
"addrs": [
{
"host": "2001:db8::1",
"port": 1
},
{
"host": "::",
"port": 65535
},
{
"host": "fe80:0:0:1:0:0:0:1",
"port": 80
}
],
"secret": "41242b9fae56fad4e6e77dfe33cb18d1",
"name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"out": "sen://AQADBiABDbgAAAAAAAAAAAAAAAEAAQYAAAAAAAAAAAAAAAAAAAAA__8G_oAAAAAAAAEAAAAAAAAAAQBQQSQrn65W-tTm533-M8sY0auEMnNtF8YBXD8UTU7398FgRU4NbbHNni55pp4LqVXwQHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHhMjeF6"
},
{
"tls": false,
"addrs": [
{
"host": "1:2:3:4:5:6:7:8",
"port": 2
}
],
"secret": "5b840157e7e86aef3b3fd0fc24f3add3",
"name": "emoji 😀 & <>",
"out": "sen://AQABBgABAAIAAwAEAAUABgAHAAgAAluEAVfn6GrvOz_Q_CTzrdOrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8A9lbW9qaSDwn5iAICYgPD6UZmVs"
}
],
"encode_errors": [
{
"tls": false,
"addrs": [],
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
"name": "",
"error": "Адресов должно быть от 1 до 3"
},
{
"tls": false,
"addrs": [
{
"host": "256.1.1.1",
"port": 1
}
],
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
"name": "",
"error": "Неверный IPv4: 256.1.1.1"
},
{
"tls": false,
"addrs": [
{
"host": "1::2::3",
"port": 1
}
],
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
"name": "",
"error": "Неверный IPv6: 1::2::3"
},
{
"tls": false,
"addrs": [
{
"host": "bad_host",
"port": 1
}
],
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
"name": "",
"error": "Неверный домен: bad_host"
},
{
"tls": false,
"addrs": [
{
"host": "a.b",
"port": 0
}
],
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
"name": "",
"error": "Неверный порт: 0"
},
{
"tls": false,
"addrs": [
{
"host": "a.b",
"port": 1
}
],
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
"name": "яяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяя",
"error": "Имя: до 64 байт"
},
{
"tls": true,
"addrs": [
{
"host": "a.b",
"port": 1
}
],
"secret": "ca978112ca1bbdcafac231b39a23dc4d",
"name": "",
"error": "tlsPin: 32 байта при TLS"
}
],
"decode": [
{
"in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg",
"tls": false,
"addrs": [
{
"host": "203.0.113.7",
"port": 8443
}
],
"secret": "e8bc163c82eee18733288c7d4ac636db",
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
"tlsPin": null,
"name": "Семья"
},
{
"in": "sen://AQEBRA92cG4uZXhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk",
"tls": true,
"addrs": [
{
"host": "vpn.example.com",
"port": 443
}
],
"secret": "ad328846aa18b32a335816374511cac1",
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
"tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812",
"name": ""
},
{
"in": "sen://AQADBiABDbgAAAAAAAAAAAAAAAEAAQYAAAAAAAAAAAAAAAAAAAAA__8G_oAAAAAAAAEAAAAAAAAAAQBQQSQrn65W-tTm533-M8sY0auEMnNtF8YBXD8UTU7398FgRU4NbbHNni55pp4LqVXwQHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHhMjeF6",
"tls": false,
"addrs": [
{
"host": "2001:db8::1",
"port": 1
},
{
"host": "::",
"port": 65535
},
{
"host": "fe80:0:0:1::1",
"port": 80
}
],
"secret": "41242b9fae56fad4e6e77dfe33cb18d1",
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
"tlsPin": null,
"name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
{
"in": "sen://AQABBgABAAIAAwAEAAUABgAHAAgAAluEAVfn6GrvOz_Q_CTzrdOrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8A9lbW9qaSDwn5iAICYgPD6UZmVs",
"tls": false,
"addrs": [
{
"host": "1:2:3:4:5:6:7:8",
"port": 2
}
],
"secret": "5b840157e7e86aef3b3fd0fc24f3add3",
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
"tlsPin": null,
"name": "emoji 😀 & <>"
},
{
"in": " SEN://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg \n",
"tls": false,
"addrs": [
{
"host": "203.0.113.7",
"port": 8443
}
],
"secret": "e8bc163c82eee18733288c7d4ac636db",
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
"tlsPin": null,
"name": "Семья"
},
{
"in": "sen://AQEBRA92cG4uZX\nhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk",
"tls": true,
"addrs": [
{
"host": "vpn.example.com",
"port": 443
}
],
"secret": "ad328846aa18b32a335816374511cac1",
"signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0",
"tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812",
"name": ""
},
{
"in": "http://x",
"error": "Ссылка должна начинаться с sen://"
},
{
"in": "sen://",
"error": "Ссылка повреждена: недопустимые символы"
},
{
"in": "sen://AAAA",
"error": "Ссылка слишком короткая"
},
{
"in": "sen://a+b",
"error": "Ссылка повреждена: недопустимые символы"
},
{
"in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdg",
"error": "Ссылка повреждена: не сошлась контрольная сумма"
},
{
"in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwA",
"error": "Ссылка повреждена: не сошлась контрольная сумма"
}
],
"sig_string": [
{
"method": "post",
"path": "/sub/v1/register",
"ts": 1700000000,
"body": "{\"a\":1}",
"out": "POST\n/sub/v1/register\n1700000000\n015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862",
"sig": "CHqkcC2BwATFKxopbQlDvSDNKCRzAxKaynJ0SDWuj-ht2L5tFcDmIsjFL2vnKiaDuNSCr-ABbWR_uzgIj7mfCw"
},
{
"method": "GET",
"path": "/sub/v1/config",
"ts": 1,
"body": "",
"out": "GET\n/sub/v1/config\n1\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"sig": "9p4dzFWv-JeWYVzxxdhjeEMIz1uH_XDVAea94YwQmYTE3j9-9hGRR6mVRs9T4szHvBUExnyXXiApOXEDkd3zCQ"
}
],
"sign_response": [
{
"in": {
"config": {
"rev": "ab",
"servers": []
},
"device": 3,
"ts": 5
},
"out": {
"body": "{\"config\":{\"rev\":\"ab\",\"servers\":[]},\"device\":3,\"ts\":5}",
"sig": "jcH9kAuxkvyBKJFLR-uSbaQgEh6dede75PLezg0v7yATUQojATafYKbQ8-aJAjvlHbv4G04j1eMD5owW9tQZDw"
}
},
{
"in": {
"error": "revoked"
},
"out": {
"body": "{\"error\":\"revoked\"}",
"sig": "Eb9F2GY-lyqk7ZxxKYHxSTl-goq7oUCYxBwguLpSw1oQmrXVFpUehQdd6_02UfjtDX1Rtth4-uf8-t4XFuciAA"
}
}
]
}
+17
View File
@@ -0,0 +1,17 @@
// Состояние после сценария awg-ui: строки ui.db и пиры/пользователи awg-ctrl.
import fs from "fs";
import { execSync } from "child_process";
const out = process.argv[2];
const t = JSON.parse(fs.readFileSync(out, "utf8"));
const q = (db, sql) => JSON.parse(execSync(`sqlite3 -json ${db} "${sql}"`).toString() || "[]");
const UI = "/run/bench/ui.db", CT = "/etc/amnezia/amneziawg/users.db";
const tables = ["api_keys", "api_key_servers", "nodes", "master_keys", "master_servers", "devices", "device_peers",
"peer_tombstones", "server_profiles", "sub_meta", "metrics_token"];
t.state = {
ui: Object.fromEntries(tables.map(x => [x, q(UI, `select * from ${x} order by rowid`)])),
uiSchema: execSync(`sqlite3 ${UI} .schema`).toString(),
ctrlUsers: q(CT, "select name, ip, key_gen from users order by rowid"),
ctrlPeers: q(CT, "select pub_key, ip, owner from peers order by rowid"),
};
fs.writeFileSync(out, JSON.stringify(t, null, 1));
console.log("collect-ui: done");
+61
View File
@@ -0,0 +1,61 @@
// compare-ui.mjs <ts.json> <go.json>: расхождения двух прогонов scenario-ui. Случайное
// маскируется (токены, ключи, uuid, ссылки, подписи, время, id панели); подписанные ответы
// /sub/v1 раскрываются: сравнивается body, а подпись — только что она есть.
import fs from "fs";
const [a, b] = process.argv.slice(2).map(f => JSON.parse(fs.readFileSync(f, "utf8")));
const panelOf = r => r.state.ui.sub_meta.find(x => x.key === "panel_id")?.value ?? "";
const TIME_KEYS = /^(created_at|last_used|last_seen|at|ts|updated_at|join_expires|revoked_at|expires_in_at)$/;
function mask(s, panel) {
if (panel) s = s.split(panel).join("<panel>");
return s
.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<jwt>")
.replace(/awg[km]_[A-Za-z0-9_-]{8}(…|[A-Za-z0-9_-]{24})/g, "<token>")
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
.replace(/sen:\/\/[A-Za-z0-9_-]+/g, "<sen>")
.replace(/awgjoin:\/\/[A-Za-z0-9_-]+/g, "<join>")
.replace(/awg_scrape_duration_seconds [0-9.e-]+/g, "awg_scrape_duration_seconds <d>")
.replace(/(awg_host_[a-z_]+\{[^}]*\}) [0-9.e+-]+/g, "$1 <n>");
}
function norm(v, panel, key = "") {
if (typeof v === "string") {
if (key === "sig") return "<sig>";
if (key === "body" && v.startsWith("{")) { try { return norm(JSON.parse(v), panel); } catch {} }
return mask(v, panel);
}
if (typeof v === "number" && TIME_KEYS.test(key)) return "<t>";
if (Array.isArray(v)) return v.map(x => norm(x, panel));
if (v && typeof v === "object") {
const o = {};
for (const [k, x] of Object.entries(v)) {
if (["key_hash", "secret", "join_hash", "token_hash", "pub", "prefix"].includes(k) && x !== null) { o[k] = "<rnd>"; continue; }
if (k === "value" && typeof x === "string" && /^[[{]/.test(x)) { o[k] = norm(JSON.parse(x), panel); continue; }
o[k] = norm(x, panel, k);
}
return o;
}
return v;
}
const pa = panelOf(a), pb = panelOf(b);
let diffs = 0;
const show = (label, x, y) => {
const X = JSON.stringify(norm(x, pa)), Y = JSON.stringify(norm(y, pb));
if (X === Y) return;
diffs++;
let i = 0; while (X[i] === Y[i]) i++;
const from = Math.max(0, i - 120);
console.log(`### ${label} @${i}\n TS: …${X.slice(from, i + 300)}\n GO: …${Y.slice(from, i + 300)}`);
};
if (a.error || b.error) console.log("scenario error:", a.error, "|", b.error);
const n = Math.max(a.steps.length, b.steps.length);
for (let i = 0; i < n; i++) {
const s = a.steps[i] ?? {}, t = b.steps[i] ?? {};
// text не сравниваем: порядок ключей виден и в разобранном body (Object.entries его хранит).
// Стек Express в dev-режиме (NODE_ENV не задан) у Go не воспроизводится — сравниваем тип ошибки.
const html = x => typeof x === "string" ? x.replace(/<pre>([A-Za-z]+Error)[^<]*(<br>[\s\S]*)?<\/pre>/, "<pre>$1</pre>") : x;
show(`${i} ${s.name ?? t.name} ${s.method} ${s.path}`, { st: s.status, ct: s.ctype, body: html(s.body) }, { st: t.status, ct: t.ctype, body: html(t.body) });
}
for (const k of Object.keys(a.state.ui)) show("ui." + k, a.state.ui[k], b.state.ui[k]);
show("uiSchema", a.state.uiSchema.replace(/\s+/g, " "), b.state.uiSchema.replace(/\s+/g, " "));
show("ctrlUsers", a.state.ctrlUsers, b.state.ctrlUsers);
show("ctrlPeers", a.state.ctrlPeers, b.state.ctrlPeers);
console.log(diffs ? `${diffs} differences` : "IDENTICAL");
+31
View File
@@ -0,0 +1,31 @@
// compare.mjs <ts.json> <go.json>: расхождения двух прогонов стенда. vpn:// раскрываются
// (сжатие у Node и Go разное), время маскируется.
import fs from "fs"; import zlib from "zlib";
const [a, b] = process.argv.slice(2).map(f => JSON.parse(fs.readFileSync(f, "utf8")));
const dec = s => { try { const buf = Buffer.from(s.slice(6), "base64url"); return "vpn<" + zlib.inflateSync(buf.subarray(4)).toString() + ">"; } catch (e) { return "vpn<undecodable " + e.message + ">"; } };
const norm = v => {
if (typeof v === "string") {
let s = v.replace(/vpn:\/\/[A-Za-z0-9_-]+/g, dec);
s = s.replace(/\d{4}-\d\d-\d\dT[\d:.-]+Z/g, "<ts>").replace(/"(created_at|lastHandshake|ts|time|uptime|generated_at)":\d+/g, '"$1":<n>');
return s;
}
if (Array.isArray(v)) return v.map(norm);
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, /^(created_at|lastHandshake|ts|uptime)$/.test(k) && typeof x === "number" ? "<n>" : norm(x)]));
return v;
};
// Известный шум: живые цифры хоста (порядок ключей сохраняем), стек Express в dev-режиме,
// порядок вызовов из параллельных фоновых задач, отступы в CREATE TABLE.
const host = h => h && { cpu: Object.keys(h.cpu), cores: h.cores, load: h.load?.length, mem: Object.keys(h.mem) };
for (const r of [a, b]) {
for (const s of r.steps) {
if (s.body?.host) { s.body.host = host(s.body.host); s.text = JSON.stringify(s.body); }
if (s.ctype === "text/html") s.body = s.body.replace(/<pre>([A-Za-z]+Error)[^<]*(<br>[\s\S]*)?<\/pre>/, "<pre>$1</pre>");
}
r.state.stubLog = r.state.stubLog.map(e => JSON.stringify(e).replace(/awg_(sync|psk)_\d+/g, "awg_$1_N")).sort();
r.state.schema = r.state.schema.replace(/\s+/g, " ");
}
let diffs = 0;
const show = (label, x, y) => { const X = JSON.stringify(norm(x)), Y = JSON.stringify(norm(y)); if (X !== Y) { diffs++; let i = 0; while (X[i] === Y[i]) i++; const from = Math.max(0, i - 150); console.log(`### ${label} @${i}\n TS: …${X.slice(from, i + 350)}\n GO: …${Y.slice(from, i + 350)}`); } };
a.steps.forEach((s, i) => { const t = b.steps[i]; show(`${i} ${s.name}`, { st: s.status, ct: s.ctype, body: s.body, text: s.text }, { st: t.status, ct: t.ctype, body: t.body, text: t.text }); });
for (const k of Object.keys(a.state)) show("state." + k, a.state[k], b.state[k]);
console.log(diffs ? `${diffs} differences` : "IDENTICAL");
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# local-ui.sh <ts|go> <имя-прогона> — дифференциальный прогон awg-ui без Docker: awg-ctrl (Go,
# на заглушках, сверен с TS отдельно) + панель нужной реализации + нода из Go-агента на тот же
# awg-ctrl. Как и local.sh — только для одноразовой машины: пересоздаёт /etc/amnezia.
# Нужны node 20+, sqlite3, openssl, go и `npm install` в awg-ui/ (и собранный awg-ui/dist).
set -e
IMPL="$1"; NAME="$2"
B=$(cd "$(dirname "$0")" && pwd); ROOT=$(cd "$B/../.." && pwd)
BENCH=/var/lib/uibench
mkdir -p "$B/bin/stubs" "$B/out" "$BENCH"
for c in awg awg-quick ip tc nft modinfo; do
printf '#!/bin/sh\nexec node %s %s "$@"\n' "$B/stubs/stub.mjs" "$c" > "$B/bin/stubs/$c"; chmod +x "$B/bin/stubs/$c"
done
export PATH="$B/bin/stubs:$PATH"
(cd "$ROOT" && go build ${RACE:+-race} -o "$B/bin/awg-control" ./cmd/awg-control) # RACE=1 — с детектором гонок
# Ключи стенда — одни на все прогоны: подпись sen://, сертификат хаба нод.
[ -f "$BENCH/sub_sign.key" ] || openssl genpkey -algorithm ed25519 -out "$BENCH/sub_sign.key" 2>/dev/null
[ -f "$BENCH/node_tls.key" ] || openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout "$BENCH/node_tls.key" -out "$BENCH/node_tls.crt" -days 3650 -subj /CN=awg-node 2>/dev/null
sh "$B/setup.sh"
PORT=3005 SERVER_IP=203.0.113.7 SERVER_PORT=47619 SERVER_NAME=VPN \
INTERNAL_AUTH_PUB_FILE=/etc/amnezia/amneziawg/internal_auth_public.key SHAPE_SAMPLE_S=3600 AWG_CLI=/nonexistent/awg-ctrl \
sh -c 'cd /tmp && exec "$0" ctrl' "$B/bin/awg-control" > /run/bench/ctrl.log 2>&1 &
CTRL=$!
i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $i -gt 150 ] && { cat /run/bench/ctrl.log; kill $CTRL; exit 1; }; sleep 0.1; done
export PORT=8080 UI_USER=admin UI_PASS=benchpass JWT_SECRET=benchsecret UI_DB_FILE=/run/bench/ui.db AWGCTRL_PORT=3005 \
INTERNAL_AUTH_KEY_FILE=/etc/amnezia/amneziawg/internal_auth_private.key \
SUB_PORT=8443 SUB_TLS=off SUB_SIGN_KEY_FILE="$BENCH/sub_sign.key" SUB_HOST=sub.example.com \
NODE_PORT=8444 NODE_TLS_KEY_FILE="$BENCH/node_tls.key" NODE_TLS_CERT_FILE="$BENCH/node_tls.crt" \
BRAND=Bench CHANNEL=Beta VERSION=0.0.0-bench MONITOR_HASH_FILE="$BENCH/none" PROM_URL=http://127.0.0.1:9 \
METRICS_PEERS=on AUDIT=on LOCAL_NODE=on
cd "$ROOT/awg-ui"
if [ "$IMPL" = "ts" ]; then node node_modules/.bin/tsx server.ts > /run/bench/ui.log 2>&1 &
else "$B/bin/awg-control" ui > /run/bench/ui.log 2>&1 &
fi
UIPID=$!
i=0; until curl -s -o /dev/null http://127.0.0.1:8080/ui/brand; do i=$((i+1)); [ $i -gt 200 ] && { cat /run/bench/ui.log; kill $UIPID $CTRL; exit 1; }; sleep 0.1; done
sleep 1
AGENT_BIN="$B/bin/awg-control" BENCH_DIR="$BENCH" "$ROOT/awg-ui/node_modules/.bin/tsx" "$B/scenario-ui.ts" "$B/out/$NAME.json" || true
sleep 2
kill $UIPID $CTRL 2>/dev/null || true; wait $UIPID $CTRL 2>/dev/null || true
node "$B/collect-ui.mjs" "$B/out/$NAME.json"
cp /run/bench/ui.log "$B/out/$NAME.ui.log"; cp /run/bench/ctrl.log "$B/out/$NAME.ctrl.log"
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# local.sh <ts|go> <имя-прогона> — тот же прогон, что run.sh, но без Docker: на одноразовой
# машине (CI-раннер, облачная песочница). Заглушки кладутся в свой каталог и попадают только
# в PATH этого прогона; ВНИМАНИЕ: setup.sh пересоздаёт /etc/amnezia — не запускать на сервере.
# Нужны node 20+, sqlite3, openssl, go; npm install в awg-ctrl/ делается при первом запуске.
# Окружение то же: GEN, WITH_I1, S4, STUB_MODULE. Результат — tools/diffbench/out/<имя>.json.
set -e
IMPL="$1"; NAME="$2"
B=$(cd "$(dirname "$0")" && pwd); ROOT=$(cd "$B/../.." && pwd)
mkdir -p "$B/bin/stubs" "$B/out"
for c in awg awg-quick ip tc nft modinfo; do
printf '#!/bin/sh\nexec node %s %s "$@"\n' "$B/stubs/stub.mjs" "$c" > "$B/bin/stubs/$c"; chmod +x "$B/bin/stubs/$c"
done
export PATH="$B/bin/stubs:$PATH"
if [ "$IMPL" = "go" ]; then (cd "$ROOT" && go build -o "$B/bin/awg-control" ./cmd/awg-control)
elif [ ! -d "$ROOT/awg-ctrl/node_modules" ]; then (cd "$ROOT/awg-ctrl" && npm install --no-audit --no-fund --loglevel=error --no-package-lock); fi
sh "$B/setup.sh"
export PORT=3005 SERVER_IP=203.0.113.7 SERVER_PORT=47619 SERVER_NAME=VPN \
INTERNAL_AUTH_PUB_FILE=/etc/amnezia/amneziawg/internal_auth_public.key \
SHAPE_SAMPLE_S=3600 AWG_CLI=/nonexistent/awg-ctrl
cd "$ROOT/awg-ctrl"
if [ "$IMPL" = "ts" ]; then node node_modules/.bin/tsx index.ts > /run/bench/ctrl.log 2>&1 &
else "$B/bin/awg-control" ctrl > /run/bench/ctrl.log 2>&1 &
fi
PID=$!
i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $i -gt 150 ] && { cat /run/bench/ctrl.log; kill $PID; exit 1; }; sleep 0.1; done
sleep 1
cd "$B"
node scenario-ctrl.mjs "$B/out/$NAME.json"
sleep 3
kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true
node collect.mjs "$B/out/$NAME.json"
cp /run/bench/ctrl.log "$B/out/$NAME.log"
+2
View File
@@ -17,6 +17,8 @@ i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $
sleep 1 # стартовый apply шейпинга асинхронный — даём ему отработать до сценария
cd /bench
node scenario-ctrl.mjs "/out/$NAME.json"
# фоновый apply шейпинга после restart/upgrade должен доработать, иначе журнал заглушек обрежется
sleep 3
kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true
node collect.mjs "/out/$NAME.json"
cp /run/bench/ctrl.log "/out/$NAME.log"
+301
View File
@@ -0,0 +1,301 @@
// Сценарий для awg-ui: одинаковая последовательность запросов к TS- и Go-версии панели.
// Запуск: awg-ui/node_modules/.bin/tsx tools/diffbench/scenario-ui.ts <out.json>
// Окружение: UI (http://127.0.0.1:8080), SUB (http://127.0.0.1:8443), AGENT_BIN — бинарник
// awg-control для ноды (join по строке из панели), BENCH_DIR — каталог с ключами стенда.
import crypto from "crypto";
import fs from "fs";
import { spawn, execSync, type ChildProcess } from "child_process";
import { decodeSenLink, signRequest } from "../../awg-ui/sen";
const UI = process.env.UI ?? "http://127.0.0.1:8080";
const SUB = process.env.SUB ?? "http://127.0.0.1:8443";
const out = process.argv[2];
const BENCH = process.env.BENCH_DIR ?? "/var/lib/uibench";
type Step = { name: string; method: string; path: string; status: number; ctype: string; body: unknown; text?: string };
const steps: Step[] = [];
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
async function call(name: string, method: string, path: string, body?: unknown,
o: { token?: string | null; key?: string; raw?: string; headers?: Record<string, string>; base?: string } = {}) {
const h: Record<string, string> = { ...(o.headers ?? {}) };
if (o.token) h.authorization = `Bearer ${o.token}`;
if (o.key) h["x-api-key"] = o.key;
let data: string | undefined;
if (o.raw !== undefined) { h["content-type"] = "application/json"; data = o.raw; }
else if (body !== undefined) { h["content-type"] = "application/json"; data = JSON.stringify(body); }
const r = await fetch((o.base ?? UI) + path, { method, headers: h, body: data });
const ctype = (r.headers.get("content-type") ?? "").split(";")[0];
const text = await r.text();
let parsed: unknown = text;
if (ctype === "application/json") { try { parsed = JSON.parse(text); } catch { /* как есть */ } }
if (ctype === "text/html" && text.length > 2000) parsed = text.slice(0, 200); // index.html
steps.push({ name, method, path, status: r.status, ctype, body: parsed, text: ctype === "application/json" ? text : undefined });
return parsed as any;
}
// ── Подписанные запросы устройства (/sub/v1) ────────────────────────────────
const seedKey = (s: string) => crypto.createPrivateKey({
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), crypto.createHash("sha256").update(s).digest()]),
format: "der", type: "pkcs8",
});
const rawPub = (k: crypto.KeyObject) => (crypto.createPublicKey(k).export({ type: "spki", format: "der" }) as Buffer).subarray(12);
const wgPub = (s: string) => crypto.createHash("sha256").update("wg:" + s).digest("base64");
let tsBump = 0;
async function subCall(name: string, method: string, path: string, body: unknown, auth: crypto.KeyObject | null, device?: number, extra: Record<string, string> = {}) {
const raw = body === undefined ? "" : JSON.stringify(body);
const ts = Math.floor(Date.now() / 1000) + (tsBump++ % 2); // разные подписи для одинаковых запросов
const h: Record<string, string> = { ...extra };
if (auth) { h["x-sen-ts"] = String(ts); h["x-sen-sig"] = signRequest(method, path, ts, raw, auth); }
if (device !== undefined) h["x-sen-device"] = String(device);
return call(name, method, path, undefined, { raw: body === undefined ? undefined : raw, headers: h, base: SUB });
}
// ── Сценарий ────────────────────────────────────────────────────────────────
async function main() {
let agent: ChildProcess | null = null;
try {
await call("brand", "GET", "/ui/brand");
await call("login bad", "POST", "/login", { user: "admin", pass: "nope" });
await call("login no body", "POST", "/login");
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("apikeys no auth", "GET", "/ui/apikeys");
await call("apikeys bad token", "GET", "/ui/apikeys", undefined, { token: "a.b.c" });
await call("nodes no auth", "GET", "/ui/nodes");
await call("proxy no auth", "GET", "/api/users");
// прокси к awg-ctrl
await call("proxy health", "GET", "/health", undefined, T);
await call("proxy users", "GET", "/api/users", undefined, T);
await call("proxy add alice", "POST", "/api/users", { name: "alice" }, T);
await call("proxy add bad", "POST", "/api/users", { name: "a b" }, T);
await call("proxy unknown", "GET", "/api/nope?x=1", undefined, T);
await call("proxy server 5", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": "5" } });
await call("proxy server junk", "GET", "/api/users/stats", undefined, { ...T, headers: { "x-server-id": "junk" } });
// ключи API
await call("apikeys empty", "GET", "/ui/apikeys", undefined, T);
await call("apikey bad label", "POST", "/ui/apikeys", { label: "bad!" }, T);
await call("apikey no label", "POST", "/ui/apikeys", {}, T);
await call("apikey bad servers", "POST", "/ui/apikeys", { label: "k", servers: [9] }, T);
await call("apikey empty servers", "POST", "/ui/apikeys", { label: "k", servers: [] }, T);
const k1 = await call("apikey create", "POST", "/ui/apikeys", { label: "main key" }, T);
const k2 = await call("apikey create sid", "POST", "/ui/apikeys", { label: "second", server_id: 0, servers: [0, 0] }, T);
await call("apikeys list", "GET", "/ui/apikeys", undefined, T);
await call("apikey patch bad", "PATCH", "/ui/apikeys/" + k2.id, { servers: [] }, T);
await call("apikey patch missing", "PATCH", "/ui/apikeys/999", { servers: [0] }, T);
await call("apikey patch", "PATCH", "/ui/apikeys/" + k2.id, { servers: [0] }, T);
await call("apikey delete bad", "DELETE", "/ui/apikeys/x", undefined, T);
await call("apikey delete", "DELETE", "/ui/apikeys/" + k2.id, undefined, T);
// внешний API: пользователи
const K = { key: k1.key as string };
await call("v1 no key", "GET", "/api/v1/users");
await call("v1 bad key", "GET", "/api/v1/users", undefined, { key: "awgk_nope" });
await call("v1 users", "GET", "/api/v1/users", undefined, K);
await call("v1 add bob", "POST", "/api/v1/users", { name: "bob" }, K);
await call("v1 add bob again", "POST", "/api/v1/users", { name: "bob" }, K);
await call("v1 add no name", "POST", "/api/v1/users", {}, K);
await call("v1 get bob", "GET", "/api/v1/users/bob", undefined, K);
await call("v1 get nobody", "GET", "/api/v1/users/nobody", undefined, K);
await call("v1 get escaped", "GET", "/api/v1/users/b%20b", undefined, K);
await call("v1 server outside", "GET", "/api/v1/users", undefined, { ...K, headers: { "x-server-id": "3" } });
await call("v1 unknown with key", "GET", "/api/v1/zzz", undefined, K);
await call("v1 delete bob", "DELETE", "/api/v1/users/bob", undefined, K);
// ноды: заводим и подключаем агента к тому же awg-ctrl
await call("nodes list", "GET", "/ui/nodes", undefined, T);
await call("node bad name", "POST", "/ui/nodes", { name: "" }, T);
const n1 = await call("node create", "POST", "/ui/nodes", { name: "edge-1", host: "127.0.0.1" }, T);
await call("node rename bad", "PATCH", "/ui/nodes/" + n1.id, { name: "a<b" }, T);
await call("node rename", "PATCH", "/ui/nodes/" + n1.id, { name: "edge-one" }, T);
await call("node rejoin", "POST", `/ui/nodes/${n1.id}/join`, { host: "127.0.0.1" }, T).then(async r => {
const j = JSON.parse(Buffer.from(String(r.join).slice("awgjoin://".length), "base64url").toString());
fs.rmSync(`${BENCH}/agent-node.key`, { force: true });
agent = spawn(process.env.AGENT_BIN!, ["agent"], {
env: { ...process.env, CORE_HOST: j.h, CORE_PORT: String(j.p), CORE_PIN: j.pin, NODE_ID: String(j.n), JOIN_SECRET: j.s,
AWGCTRL_PORT: "3005", NODE_KEY_FILE: `${BENCH}/agent-node.key`, VERSION: "0.9.9",
INTERNAL_AUTH_KEY_FILE: "/etc/amnezia/amneziawg/internal_auth_private.key", CLI_ENV_FILE: "" },
stdio: "ignore",
});
});
for (let i = 0; i < 100; i++) {
const r = await fetch(UI + "/ui/nodes", { headers: { authorization: `Bearer ${token}` } }).then(x => x.json()) as any;
if (r.nodes.some((n: any) => n.id === n1.id && n.online && n.health)) break;
await sleep(100);
}
await sleep(500);
await call("nodes online", "GET", "/ui/nodes", undefined, T);
await call("node join bound", "POST", `/ui/nodes/${n1.id}/join`, {}, T);
await call("proxy via node", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
await call("proxy via node awg", "GET", "/awg/status", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
// мастер-ключи
await call("mk list empty", "GET", "/ui/masterkeys", undefined, T);
await call("mk bad label", "POST", "/ui/masterkeys", { label: "a\nb" }, T);
await call("mk bad limit", "POST", "/ui/masterkeys", { label: "x", device_limit: 0 }, T);
await call("mk bad servers", "POST", "/ui/masterkeys", { label: "x", servers: [42] }, T);
const m1 = await call("mk create", "POST", "/ui/masterkeys", { label: "Семья", device_limit: 2 }, T);
const m2 = await call("mk create multi", "POST", "/ui/masterkeys", { label: "Multi", servers: [n1.id, 0] }, T);
await call("mk list", "GET", "/ui/masterkeys", undefined, T);
const link = await call("mk link", "GET", `/ui/masterkeys/${m1.id}/link`, undefined, T);
await call("mk link by uuid", "GET", `/ui/masterkeys/${m1.uuid.toUpperCase()}/link`, undefined, T);
await call("mk link missing", "GET", "/ui/masterkeys/999/link", undefined, T);
const sen = decodeSenLink(link.link);
const secret = sen.secret.toString("base64url");
// /sub/v1: регистрация и опрос устройства
const d1 = seedKey("dev1"), d2 = seedKey("dev2"), d3 = seedKey("dev3");
const reg = (dev: string, k: crypto.KeyObject, sec: string, extra: object = {}) => ({
sub: sec, device_id: dev, device_name: "Phone " + dev, platform: "android", pub_key: wgPub(dev),
auth_pub: rawPub(k).toString("base64url"), version: "1.2.3", ...extra,
});
await subCall("sub unknown path", "GET", "/sub/v1/nope", undefined, null);
await subCall("sub broken json", "POST", "/sub/v1/peek", undefined, null).catch(() => {});
await call("sub broken json raw", "POST", "/sub/v1/peek", undefined, { raw: "{", base: SUB });
await subCall("sub peek bad", "POST", "/sub/v1/peek", { sub: "x" }, null);
await subCall("sub peek missing", "POST", "/sub/v1/peek", { sub: "A".repeat(22) }, null);
await subCall("sub peek", "POST", "/sub/v1/peek", { sub: secret }, null);
await subCall("sub register bad", "POST", "/sub/v1/register", { sub: secret }, d1);
await subCall("sub register unsigned", "POST", "/sub/v1/register", reg("d1", d1, secret), null);
const r1 = await subCall("sub register d1", "POST", "/sub/v1/register", reg("d1", d1, secret), d1, { "x-sen-device": "77" });
const dev1 = JSON.parse(r1.body).device;
const r2 = await subCall("sub register d2", "POST", "/sub/v1/register", reg("d2", d2, secret), d2);
const dev2 = JSON.parse(r2.body).device;
await subCall("sub register over limit", "POST", "/sub/v1/register", reg("d3", d3, secret), d3);
await subCall("sub config", "GET", "/sub/v1/config", undefined, d1, dev1, { "x-sen-version": "1.2.4" });
await subCall("sub config wrong key", "GET", "/sub/v1/config", undefined, d2, dev1);
await subCall("sub config no sig", "GET", "/sub/v1/config", undefined, null, dev1);
await subCall("sub config gone", "GET", "/sub/v1/config", undefined, d1, 9999);
await subCall("sub devices", "GET", "/sub/v1/devices", undefined, d2, dev2);
await subCall("sub rekey bad", "POST", "/sub/v1/rekey", { pub_key: "x" }, d1, dev1);
await subCall("sub rekey same", "POST", "/sub/v1/rekey", { pub_key: wgPub("d1") }, d1, dev1);
await subCall("sub rekey taken", "POST", "/sub/v1/rekey", { pub_key: wgPub("d2") }, d1, dev1);
await subCall("sub rekey", "POST", "/sub/v1/rekey", { pub_key: wgPub("d1-new") }, d1, dev1);
// повтор той же подписи — replay
{
const ts = Math.floor(Date.now() / 1000);
const sig = signRequest("GET", "/sub/v1/config", ts, "", d2);
const h = { "x-sen-ts": String(ts), "x-sen-sig": sig, "x-sen-device": String(dev2) };
await call("sub config sig once", "GET", "/sub/v1/config", undefined, { headers: h, base: SUB });
await call("sub config replay", "GET", "/sub/v1/config", undefined, { headers: h, base: SUB });
}
// устройство на ключе с двумя серверами (нода — тот же awg-ctrl)
const link2 = await call("mk2 link", "GET", `/ui/masterkeys/${m2.id}/link`, undefined, T);
const secret2 = decodeSenLink(link2.link).secret.toString("base64url");
const r3 = await subCall("sub register multi", "POST", "/sub/v1/register", reg("d3", d3, secret2), d3);
const dev3 = JSON.parse(r3.body).device;
await subCall("sub config multi", "GET", "/sub/v1/config", undefined, d3, dev3);
// панель: устройства и мастер-ключи
await call("mk devices", "GET", `/ui/masterkeys/${m1.id}/devices`, undefined, T);
await call("mk2 devices", "GET", `/ui/masterkeys/${m2.id}/devices`, undefined, T);
await call("dev rekey", "POST", `/ui/devices/${dev2}/rekey`, undefined, T);
await call("dev psk", "POST", `/ui/devices/${dev2}/psk`, undefined, T);
await call("dev missing", "POST", "/ui/devices/999/psk", undefined, T);
await call("mk rekey", "POST", `/ui/masterkeys/${m1.id}/rekey`, undefined, T);
await subCall("sub config after rekey flag", "GET", "/sub/v1/config", undefined, d2, dev2);
await call("mk patch bad", "PATCH", `/ui/masterkeys/${m1.id}`, { device_limit: 101 }, T);
await call("mk patch limit", "PATCH", `/ui/masterkeys/${m1.id}`, { label: "Семья 2", device_limit: 1 }, T);
await subCall("sub config unbound", "GET", "/sub/v1/config", undefined, d2, dev2);
await call("mk2 patch servers", "PATCH", `/ui/masterkeys/${m2.id}`, { servers: [0] }, T);
await call("mk rotate", "POST", `/ui/masterkeys/${m1.id}/rotate`, undefined, T);
await subCall("sub peek old secret", "POST", "/sub/v1/peek", { sub: secret }, null);
await call("dev delete", "DELETE", `/ui/devices/${dev1}`, undefined, T);
await subCall("sub config deleted", "GET", "/sub/v1/config", undefined, d1, dev1);
await subCall("sub delete self", "DELETE", "/sub/v1/device", undefined, d3, dev3);
// внешний API: мастер-ключи
await call("v1 mk list", "GET", "/api/v1/masterkeys", undefined, K);
await call("v1 mk create outside", "POST", "/api/v1/masterkeys", { label: "ext", servers: [n1.id] }, K);
const em = await call("v1 mk create", "POST", "/api/v1/masterkeys", { label: "ext" }, K);
await call("v1 mk get", "GET", `/api/v1/masterkeys/${em.id}`, undefined, K);
await call("v1 mk get by uuid", "GET", `/api/v1/masterkeys/${em.uuid}`, undefined, K);
await call("v1 mk get invisible", "GET", `/api/v1/masterkeys/${m2.id}`, undefined, K);
await call("v1 mk patch", "PATCH", `/api/v1/masterkeys/${em.id}`, { device_limit: 5 }, K);
await call("v1 mk rotate", "POST", `/api/v1/masterkeys/${em.id}/rotate`, undefined, K);
await call("v1 mk rekey", "POST", `/api/v1/masterkeys/${em.id}/rekey`, undefined, K);
await call("v1 mk devices", "GET", `/api/v1/masterkeys/${em.id}/devices`, undefined, K);
await call("v1 mk device missing", "DELETE", `/api/v1/masterkeys/${em.id}/devices/${dev2}`, undefined, K);
await call("v1 mk delete", "DELETE", `/api/v1/masterkeys/${em.id}`, undefined, K);
// аудит
await call("audit get", "GET", "/ui/masterkeys/audit", undefined, T);
await call("audit settings bad", "PUT", "/ui/masterkeys/audit/settings", { every_h: 0 }, T);
await call("audit settings", "PUT", "/ui/masterkeys/audit/settings", { clean: false, every_h: 2, max: 5 }, T);
await call("audit run", "POST", "/ui/masterkeys/audit", undefined, T);
await call("audit clean", "POST", "/ui/masterkeys/audit/clean", undefined, T);
// метрики
await call("metrics no token", "GET", "/metrics");
await call("metrics token none", "GET", "/ui/metrics-token", undefined, T);
const mt = await call("metrics token create", "POST", "/ui/metrics-token", undefined, T);
await call("metrics", "GET", "/metrics", undefined, { token: mt.token });
await call("metrics token view", "GET", "/ui/metrics-token", undefined, T);
await call("metrics token delete", "DELETE", "/ui/metrics-token", undefined, T);
await call("metrics after delete", "GET", "/metrics", undefined, { token: mt.token });
// монитор (Prometheus не запущен)
await call("monitor", "GET", "/ui/monitor", undefined, T);
await call("monitor overview bad", "GET", "/ui/monitor/overview?range=2h", undefined, T);
await call("monitor overview", "GET", "/ui/monitor/overview?range=6h", undefined, T);
// аудит с «мёртвыми»: лишний пир панели у awg-ctrl и пир в ядре без записи
{
const panel = fs.readFileSync(__dirname + "/../../awg-ui/panel.id", "utf8").trim();
await call("orphan add", "POST", "/api/peers", { pub_key: wgPub("orphan"), owner: `${panel}/m1/d77` }, T);
await call("foreign add", "POST", "/api/peers", { pub_key: wgPub("foreign"), owner: "ffffffffffff/m1/d1" }, T);
execSync(`awg set awg1 peer ${wgPub("ghost")} allowed-ips 10.9.9.9/32`);
await call("audit run 1", "POST", "/ui/masterkeys/audit", undefined, T);
await call("audit clean unconfirmed", "POST", "/ui/masterkeys/audit/clean", undefined, T);
await call("audit run 2", "POST", "/ui/masterkeys/audit", undefined, T);
await call("audit clean confirmed", "POST", "/ui/masterkeys/audit/clean", undefined, T);
await call("audit after clean", "GET", "/ui/masterkeys/audit", undefined, T);
}
// ключ API на ноде мешает её удалить; потом нода уходит офлайн
const k3 = await call("apikey on node", "POST", "/ui/apikeys", { label: "node only", servers: [n1.id] }, T);
await call("node delete key veto", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
await call("apikey to both", "PATCH", "/ui/apikeys/" + k3.id, { servers: [0, n1.id] }, T);
await call("v1 users on node", "GET", "/api/v1/users", undefined, { key: k3.key, headers: { "x-server-id": String(n1.id) } });
if (agent) { (agent as ChildProcess).kill(); agent = null; }
for (let i = 0; i < 50; i++) {
const r = await fetch(UI + "/ui/nodes", { headers: { authorization: `Bearer ${token}` } }).then(x => x.json()) as any;
if (r.nodes.some((n: any) => n.id === n1.id && !n.online)) break;
await sleep(100);
}
await call("nodes offline", "GET", "/ui/nodes", undefined, T);
await call("proxy via offline node", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
await call("mk2 back to node", "PATCH", `/ui/masterkeys/${m2.id}`, { servers: [0, n1.id] }, T);
await call("mk2 devices offline", "GET", `/ui/masterkeys/${m2.id}/devices`, undefined, T);
// удаление ноды
await call("node delete in use", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
await call("mk2 delete", "DELETE", `/ui/masterkeys/${m2.id}`, undefined, T);
await call("node delete pending", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
await call("node delete", "DELETE", `/ui/nodes/${n1.id}?force=1`, undefined, T);
await call("apikeys after node delete", "GET", "/ui/apikeys", undefined, T);
await call("node delete again", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
await call("mk list final", "GET", "/ui/masterkeys", undefined, T);
// статика и общие ответы
await call("static index", "GET", "/");
await call("static spa", "GET", "/some/route");
await call("static ui fallback", "GET", "/ui/unknown", undefined, T);
await call("post unknown", "POST", "/nope");
await call("wrong method", "PUT", "/ui/apikeys", undefined, T);
await call("logout", "POST", "/logout", undefined, T);
await call("after logout", "GET", "/ui/apikeys", undefined, T);
} finally {
if (agent) (agent as ChildProcess).kill();
}
fs.writeFileSync(out, JSON.stringify({ steps }, null, 1));
console.log(`scenario-ui: ${steps.length} steps → ${out}`);
}
main().catch(e => { console.error(e); fs.writeFileSync(out, JSON.stringify({ steps, error: String(e) }, null, 1)); process.exit(1); });
+63
View File
@@ -0,0 +1,63 @@
// Эталон для internal/ui (sen.go): ссылки sen:// и подписи протокола, как их делает awg-ui/sen.ts.
// Запуск: awg-ui/node_modules/.bin/tsx tools/golden/sen.ts > testdata/golden/sen.json
import crypto from "crypto";
import {
encodeSenLink, decodeSenLink, crc32, requestSigString, signResponse, signRequest, rawPublicKey,
type SenLink,
} from "../../awg-ui/sen";
const seed = (s: string) => crypto.createHash("sha256").update(s).digest();
// Ed25519 из фиксированного seed: PKCS#8 = префикс + 32 байта seed.
const keyFromSeed = (s: Buffer) => crypto.createPrivateKey({
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), s]), format: "der", type: "pkcs8",
});
const signKey = keyFromSeed(seed("sign"));
const signPub = rawPublicKey(crypto.createPublicKey(signKey));
const hex = (b?: Buffer) => b?.toString("hex");
const links: SenLink[] = [
{ tls: false, addrs: [{ host: "203.0.113.7", port: 8443 }], secret: seed("s1").subarray(0, 16), signPub, name: "Семья" },
{ tls: true, addrs: [{ host: "vpn.example.com", port: 443 }], secret: seed("s2").subarray(0, 16), signPub, tlsPin: seed("pin"), name: "" },
{ tls: false, addrs: [{ host: "2001:db8::1", port: 1 }, { host: "::", port: 65535 }, { host: "fe80:0:0:1:0:0:0:1", port: 80 }],
secret: seed("s3").subarray(0, 16), signPub, name: "x".repeat(64) },
{ tls: false, addrs: [{ host: "1:2:3:4:5:6:7:8", port: 2 }], secret: seed("s4").subarray(0, 16), signPub, name: "emoji 😀 & <>" },
];
const badLinks = [
{ tls: false, addrs: [], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "256.1.1.1", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "1::2::3", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "bad_host", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "a.b", port: 0 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "a.b", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "я".repeat(33) },
{ tls: true, addrs: [{ host: "a.b", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
];
const good = links.map(encodeSenLink);
const decodeCases = [
...good, " " + good[0].toUpperCase().slice(0, 6) + good[0].slice(6) + " \n", good[1].slice(0, 20) + "\n" + good[1].slice(20),
"http://x", "sen://", "sen://AAAA", "sen://a+b", good[0].slice(0, -2), good[0].slice(0, -1) + (good[0].endsWith("A") ? "B" : "A"),
];
const err = (f: () => unknown) => { try { return { ok: f() }; } catch (e) { return { error: (e as Error).message }; } };
process.stdout.write(JSON.stringify({
sign_seed: hex(seed("sign")),
sign_pub: hex(signPub),
crc32: ["", "a", "hello", "кириллица"].map(s => ({ in: s, out: crc32(Buffer.from(s)) })),
encode: links.map((l, i) => ({
tls: l.tls, addrs: l.addrs, secret: hex(l.secret), tlsPin: hex(l.tlsPin), name: l.name, out: good[i],
})),
encode_errors: badLinks.map(l => ({
tls: l.tls, addrs: l.addrs, secret: hex(l.secret), name: l.name, error: err(() => encodeSenLink(l as SenLink)).error,
})),
decode: decodeCases.map(s => {
const r = err(() => decodeSenLink(s));
if ("error" in r) return { in: s, error: r.error };
const d = r.ok as SenLink;
return { in: s, tls: d.tls, addrs: d.addrs, secret: hex(d.secret), signPub: hex(d.signPub), tlsPin: hex(d.tlsPin) ?? null, name: d.name };
}),
sig_string: [
{ method: "post", path: "/sub/v1/register", ts: 1700000000, body: '{"a":1}' },
{ method: "GET", path: "/sub/v1/config", ts: 1, body: "" },
].map(c => ({ ...c, out: requestSigString(c.method, c.path, c.ts, c.body),
sig: signRequest(c.method, c.path, c.ts, c.body, signKey) })),
sign_response: [{ config: { rev: "ab", servers: [] }, device: 3, ts: 5 }, { error: "revoked" }].map(p => ({ in: p, out: signResponse(p, signKey) })),
}, null, 1));
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# package.sh <версия> — архивы релиза: по одному на архитектуру, awgcontrol-<версия>-linux-<arch>.tar.gz.
# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env.
# Фронтенд должен быть собран заранее (npm run build в awg-ui). Общий для GitHub и Gitea.
set -eu
VERSION="$1"
[ -f awg-ui/dist/index.html ] || { echo "нет awg-ui/dist — сначала npm run build в awg-ui"; exit 1; }
for ARCH in amd64 arm64; do
D="awgcontrol-${VERSION}"
rm -rf "$D" && mkdir -p "$D/awg-ui"
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/"
tar -czf "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz" "$D/"
rm -rf "$D"
echo "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz"
done