16 Commits
Author SHA1 Message Date
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
59 changed files with 10275 additions and 226 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.2.1
+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
+10 -8
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,7 @@
На сервере, от 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)
```
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
@@ -36,7 +38,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 +151,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.2.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-agent",
"version": "0.3.4",
"version": "0.4.2.1",
"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.2.1",
"description": "",
"main": "dist/index.js",
"scripts": {
+39 -7
View File
@@ -57,7 +57,10 @@ const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семь
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 });
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 +100,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;
@@ -118,7 +121,7 @@ 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 }); });
}
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 +139,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 +168,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 +182,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);
+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.2.1",
"private": true,
"scripts": {
"dev": "vite",
+4
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); }
+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>
);
}
+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);
+4 -2
View File
@@ -7,6 +7,7 @@ import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
@@ -16,7 +17,7 @@ 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; // на скольких серверах ключа уже стоит его текущий ключ
}
@@ -284,7 +285,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
<PriorityChip priority={d.priority} />
<PriorityChip priority={d.priority} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
<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>
+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.2.1",
"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); }
+39 -4
View File
@@ -2,18 +2,30 @@
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/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 +36,31 @@ 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)
default:
fmt.Fprintf(os.Stderr, "awg-control: неизвестная команда %q\n", os.Args[1])
os.Exit(1)
cli.Main(os.Args[1:])
}
}
+77 -88
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.2.1"
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
@@ -792,7 +804,7 @@ ask YN "Продолжить? [Y/n]: "
# Совместимость (виртуализация, заголовки ядра, интернет) проверена выше в
# секции «Проверка совместимости»; $KERNEL задан там же.
step "1/7 AmneziaWG"
step "1/6 AmneziaWG"
if [[ "$ROLE" == "core" ]]; then
skip "пропущено — у панели без VPN AmneziaWG не нужен"
@@ -1022,24 +1034,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 +1195,7 @@ CONF
fi
fi
step "5/7 Запуск AWG"
step "4/6 Запуск AWG"
if [[ "$ROLE" == "core" ]]; then
skip "пропущено — у панели без VPN интерфейса нет"
@@ -1307,21 +1306,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.
+447
View File
@@ -0,0 +1,447 @@
// 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/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"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
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 }
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"),
NodeID: atoi("NODE_ID"),
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 == "" || 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
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},
}
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)
}
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", hostPort(a.c.CoreHost, a.c.CorePort)), &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")
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, hostPort(a.c.CoreHost, a.c.CorePort))
backoff := time.Second
for {
authFailed := a.connectOnce(ctx, func() { backoff = time.Second })
wait := backoff
if authFailed {
wait = maxBackoff
}
backoff = min(backoff*2, maxBackoff)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
}
+270
View File
@@ -0,0 +1,270 @@
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())
}
}
+619
View File
@@ -0,0 +1,619 @@
// 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/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", "")},
// Часовой аудит пиров на всех серверах — только у 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_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"),
},
}
// Роль установки. 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"}
}
return c
}
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
}
// 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
}
c.start("awgctrl")
var rest, labels []string
for _, n := range c.all {
if n != "awgctrl" {
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 {
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 removeEnvVar(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")
}
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|peers [drop <область>]|monitor <install|status|remove>>"
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "peers", "monitor"}
// 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 "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)
}
}
}
+486
View File
@@ -0,0 +1,486 @@
package cli
import (
"bytes"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"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)
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") != ""
keepPanel := !hasFlag && hasPanel
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])
}
c.writeEnvFile(content)
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
dropPeers := !keepPanel && hasPanel && 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)
c.restartFresh()
}
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"} {
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 для продолжения..."))
}
}
+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
}
+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()}}
}
+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())
}
+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
}
+863
View File
@@ -0,0 +1,863 @@
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 работает
// как чистая панель. Протокол и формат 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
}
}
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 {
if !n.local {
return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
}
}
return n.a.ctrlLocal
}
if n.row(int64(id)) == 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 {
return n.local
}
return n.row(int64(id)) != nil
}
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
func (n *Nodes) IsOnline(id int) bool {
if id == 0 {
return n.local
}
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 {
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")
}
ws.Write(context.Background(), websocket.MessageText, []byte(`{"t":"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") == "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 := net.Listen("tcp", srv.Addr)
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 }
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
}
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
}
+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)
}
}
}
+949
View File
@@ -0,0 +1,949 @@
// 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"
"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
}
// 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.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())
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 := net.Listen("tcp", fmt.Sprintf(":%d", 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)
}
+1265
View File
File diff suppressed because it is too large Load Diff
+807
View File
@@ -0,0 +1,807 @@
package ui
// Мастер-ключи: общая логика панели и внешнего API (порт части awg-ui/sub.ts). Панель
// (/ui/masterkeys, /ui/devices, за JWT) видит всё. Внешний API (/api/v1/masterkeys, ключ
// awgk_) — только ключи, чьи серверы целиком входят в набор серверов API-ключа: иначе он мог бы
// удалить или перенастроить раздачу на сервере, которого не видит. scope: nil — панель.
import (
"crypto/rand"
"database/sql"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"github.com/google/uuid"
"github.com/maeneko/forgetting/internal/jsjson"
)
type apiError struct {
status int
msg string
}
func (e *apiError) Error() string { return e.msg }
func fail(status int, msg string) error { return &apiError{status, msg} }
// aw — apiError даёт свой статус; остальное (awg-ctrl не ответил посреди действия) — 502.
func aw(fn func(c *Ctx) (int, any, error)) handler {
return func(c *Ctx) bool {
var (
st int
v any
err error
)
func() {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("%v", p)
}
}()
st, v, err = fn(c)
}()
var ae *apiError
if errors.As(err, &ae) {
return c.Error(ae.status, ae.msg)
}
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
return c.JSON(st, v)
}
}
var (
reUUID = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
reLabel = regexp.MustCompile(`^[^\r\n<>]+$`) // длина — lenOK(1, 40): в JS {1,40} считает UTF-16
)
func labelOK(v any) (string, bool) {
s, ok := v.(string)
return s, ok && reLabel.MatchString(s) && lenOK(s, 1, 40)
}
// idOf — Number(v), если это безопасное целое, иначе -1 (не найдётся).
func idOf(v string) int64 {
f := jsToNumber(v)
if !isSafeInt(f) {
return -1
}
return int64(f)
}
// masterIdOf — мастер-ключ в пути: числовой id или uuid.
func (s *Sub) masterIDOf(v string) int64 {
if reUUID.MatchString(v) {
var id int64
if s.a.db.QueryRow("SELECT id FROM master_keys WHERE uuid = ?", strings.ToLower(v)).Scan(&id) != nil {
return -1
}
return id
}
return idOf(v)
}
func limitOK(v any) (int64, bool) {
f, ok := v.(float64)
return int64(f), ok && f == float64(int64(f)) && f >= 1 && f <= 100
}
func (s *Sub) visible(masterID int64, scope []int) bool {
if scope == nil {
return true
}
for _, id := range s.serverIDsOf(s.a.db, masterID) {
if !containsInt(scope, id) {
return false
}
}
return true
}
// masterFor — живой (не удаляемый) ключ в пределах области; чужой — как несуществующий.
func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) {
m := s.masterByID(s.a.db, id)
if m == nil || !s.visible(m.ID, scope) {
return nil, fail(404, "Не найден")
}
return m, nil
}
// deviceFor — устройство; у внешнего API — только вместе со своим мастер-ключом.
func (s *Sub) deviceFor(id int64, master *masterRow) (*deviceRow, error) {
d := s.deviceByID(s.a.db, id)
if d == nil || master != nil && d.MasterID != master.ID {
return nil, fail(404, "Не найдено")
}
return d, nil
}
// parseServers — набор серверов из тела: непустой, без повторов, только существующие и — для
// внешнего API — только из области его ключа.
func (s *Sub) parseServers(v any, scope []int) ([]int, error) {
list, ok := v.([]any)
if !ok || len(list) == 0 {
return nil, fail(400, "Нужен хотя бы один сервер")
}
if len(list) > maxServers {
return nil, fail(400, fmt.Sprintf("Не больше %d серверов", maxServers))
}
ids, ok := s.a.serverList(v, s.a.nodes.Exists)
if !ok {
return nil, fail(400, "Такого сервера нет")
}
if scope != nil {
for _, id := range ids {
if !containsInt(scope, id) {
return nil, fail(403, "Сервер вне области API-ключа")
}
}
}
return ids, nil
}
// masterOut — deleting: ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
func (s *Sub) masterOut(m *masterRow) jsjson.Object {
srv := intsJSON(s.serverIDsOf(s.a.db, m.ID))
o := jsjson.Object{{Key: "id", Val: m.ID}, {Key: "uuid", Val: m.UUID}, {Key: "label", Val: m.Label}, {Key: "device_limit", Val: m.DeviceLimit},
{Key: "devices", Val: m.Devices}, {Key: "servers", Val: srv}, {Key: "created_at", Val: m.CreatedAt}}
if m.RevokedAt.Valid {
o = append(o, jsjson.KV{Key: "deleting", Val: srv})
}
return o
}
func (s *Sub) masterOutByID(id int64) jsjson.Object { return s.masterOut(s.masterRowByID(s.a.db, id)) }
func (s *Sub) listMasters(scope []int) []any {
rows, err := s.a.db.Query("SELECT " + masterCols + ", (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m ORDER BY id")
if err != nil {
return []any{}
}
var ms []*masterRow
for rows.Next() {
if m := scanMaster(rows, true); m != nil {
ms = append(ms, m)
}
}
rows.Close()
out := []any{}
for _, m := range ms {
if s.visible(m.ID, scope) {
out = append(out, s.masterOut(m))
}
}
return out
}
// createMaster — серверы по умолчанию: у панели — сервер по умолчанию, у API-ключа — вся его область.
func (s *Sub) createMaster(body any, scope []int) (jsjson.Object, error) {
b, _ := body.(jsjson.Object)
label, ok := labelOK(b.Get2("label"))
if !ok {
return nil, fail(400, "Метка: до 40 символов, без переводов строк и <>")
}
limitV, hasLimit := b.Get("device_limit")
limit := int64(defaultLimit)
if hasLimit {
var ok bool
if limit, ok = limitOK(limitV); !ok {
return nil, fail(400, "Лимит устройств: от 1 до 100")
}
}
wanted, hasWanted := b.Get("servers")
src := wanted
if src == nil {
if scope != nil {
list := []any{} // как пришло бы из JSON: числа — float64
for _, id := range scope {
list = append(list, float64(id))
}
src = list
} else {
src = []any{float64(s.a.nodes.DefaultID())}
}
}
ids, err := s.parseServers(src, scope)
if err != nil {
if !hasWanted && scope == nil {
return nil, fail(400, "Сначала добавьте сервер")
}
return nil, err
}
secret := make([]byte, 16)
rand.Read(secret)
var id int64
err = s.a.tx(func(tx *sql.Tx) error {
r, err := tx.Exec("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
uuid.NewString(), label, secret, limit, ids[0], now())
if err != nil {
return err
}
id, _ = r.LastInsertId()
for _, sid := range ids {
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", id, sid); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return s.masterOutByID(id), nil
}
func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object, error) {
b, _ := body.(jsjson.Object)
nlV := b.Get2("label")
if nlV == nil {
nlV = m.Label
}
ndV := b.Get2("device_limit")
if ndV == nil {
ndV = float64(m.DeviceLimit)
}
nl, ok := labelOK(nlV)
if !ok {
return nil, fail(400, "Неверная метка")
}
nd, ok := limitOK(ndV)
if !ok {
return nil, fail(400, "Лимит устройств: от 1 до 100")
}
ids := s.serverIDsOf(s.a.db, m.ID)
var affected []int
if wanted, has := b.Get("servers"); has {
next, err := s.parseServers(wanted, scope)
if err != nil {
return nil, err
}
var added, removed []int
for _, x := range next {
if !containsInt(ids, x) {
added = append(added, x)
}
}
for _, x := range ids {
if !containsInt(next, x) {
removed = append(removed, x)
}
}
devices := s.devicesOf(s.a.db, m.ID)
if err := s.a.tx(func(tx *sql.Tx) error {
for _, sid := range removed {
tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid)
for _, d := range devices {
for _, l := range s.linksOf(tx, d.ID) {
if l.ServerID == sid && l.PeerPub.Valid {
tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String)
}
}
tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid)
}
}
for _, sid := range added {
tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid)
for _, d := range devices {
tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid)
}
}
return nil
}); err != nil {
return nil, err
}
affected = append(append(affected, added...), removed...)
ids = next
}
s.a.db.Exec("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?", nl, nd, m.ID)
// Лимит стал меньше, чем устройств: лишние отвязываются, начиная с подключившихся последними.
unbound := []any{}
devices := s.devicesOf(s.a.db, m.ID)
if int64(len(devices)) > nd {
newest := append([]*deviceRow{}, devices...)
sort.SliceStable(newest, func(i, j int) bool {
if newest[i].CreatedAt != newest[j].CreatedAt {
return newest[i].CreatedAt > newest[j].CreatedAt
}
return newest[i].ID > newest[j].ID
})
for _, d := range newest[:int64(len(devices))-nd] {
affected = append(affected, s.forgetDeviceTx(d)...)
unbound = append(unbound, d.ID)
}
}
s.reconcileAll(affected)
return jsjson.Object{{Key: "id", Val: m.ID}, {Key: "label", Val: nl}, {Key: "device_limit", Val: nd},
{Key: "servers", Val: intsJSON(ids)}, {Key: "unbound", Val: unbound}}, nil
}
// rotateMaster — новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
func (s *Sub) rotateMaster(m *masterRow) {
secret := make([]byte, 16)
rand.Read(secret)
s.a.db.Exec("UPDATE master_keys SET secret = ? WHERE id = ?", secret, m.ID)
}
func (s *Sub) rekeyMaster(m *masterRow) {
s.a.db.Exec("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?", -1, m.ID)
}
// deleteMaster — ключ сразу перестаёт работать, но строка остаётся отозванной, пока каждый его
// сервер не подтвердит, что снял пиров.
func (s *Sub) deleteMaster(m *masterRow) jsjson.Object {
affected := s.serverIDsOf(s.a.db, m.ID)
s.a.tx(func(tx *sql.Tx) error {
for _, d := range s.devicesOf(tx, m.ID) {
affected = append(affected, s.forgetDevice(tx, d)...)
}
_, err := tx.Exec("UPDATE master_keys SET revoked_at = ? WHERE id = ?", now(), m.ID)
return err
})
s.reconcileAll(affected)
return jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: m.ID}, {Key: "pending", Val: intsJSON(s.serverIDsOf(s.a.db, m.ID))}}
}
// linkProblem — выдать ссылку можно, только если подписка настроена и известен адрес панели.
func (s *Sub) linkProblem(c *Ctx) string {
if s.key() == nil || s.signPub == nil || s.port == 0 {
return "Подписка не настроена на сервере"
}
if s.subHost(c) == "" {
return "Не удалось определить адрес панели — задайте SUB_HOST"
}
return ""
}
func (s *Sub) linkOf(m *masterRow, c *Ctx) (jsjson.Object, error) {
if p := s.linkProblem(c); p != "" {
return nil, fail(503, p)
}
link, err := EncodeSenLink(SenLink{TLS: s.tlsPin != nil, Addrs: []SenAddr{{s.subHost(c), s.port}},
Secret: m.Secret, SignPub: s.signPub, TLSPin: s.tlsPin, Name: m.Label})
if err != nil {
return nil, err // как в Node: не ApiError — 502
}
return jsjson.Object{{Key: "link", Val: link}, {Key: "tls", Val: s.tlsPin != nil}}, nil
}
type devStat struct {
online bool
lastHS float64
rx, tx float64
background bool
torrent bool
}
// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий
// handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ.
func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
stats := map[string]*devStat{}
var mu sync.Mutex
var wg sync.WaitGroup
var firstErr error
for _, id := range s.serverIDsOf(s.a.db, m.ID) {
if !s.a.nodes.IsOnline(id) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
mu.Lock()
defer mu.Unlock()
if err != nil {
if firstErr == nil {
firstErr = err
}
return
}
if !ok2xx(r.Status) {
return
}
for _, p := range arr(field(r.Data, "peers")) {
pub, _ := field(p, "pub_key").(string)
st := stats[pub]
if st == nil {
st = &devStat{}
stats[pub] = st
}
st.online = st.online || truthy(field(p, "online"))
if h := numOr(field(p, "lastHandshake"), 0); h > st.lastHS {
st.lastHS = h
}
st.rx += numOr(field(p, "rx"), 0)
st.tx += numOr(field(p, "tx"), 0)
// Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них.
st.background = st.background || field(p, "priority") == "background"
st.torrent = st.torrent || truthy(field(p, "torrent"))
}
}()
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
out := []any{}
for _, d := range s.devicesOf(s.a.db, m.ID) {
links := s.linksOf(s.a.db, d.ID)
st := stats[d.PubKey]
if st == nil {
st = &devStat{}
}
okN := 0
for _, l := range links {
if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" {
okN++
}
}
prio := "normal"
if st.background {
prio = "background"
}
out = append(out, jsjson.Object{
{Key: "id", Val: d.ID}, {Key: "device_id", Val: d.DeviceID}, {Key: "device_name", Val: d.DeviceName}, {Key: "platform", Val: d.Platform},
{Key: "version", Val: d.Version}, {Key: "created_at", Val: d.CreatedAt}, {Key: "last_seen", Val: nullInt(d.LastSeen)},
{Key: "rekey_requested", Val: d.RekeyRequested == 1}, {Key: "online", Val: st.online}, {Key: "lastHandshake", Val: st.lastHS},
{Key: "rx", Val: st.rx}, {Key: "tx", Val: st.tx}, {Key: "priority", Val: prio},
{Key: "torrent", Val: st.torrent},
{Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN},
})
}
return out, nil
}
func (s *Sub) deleteDevice(d *deviceRow) jsjson.Object {
s.reconcileAll(s.forgetDeviceTx(d))
return jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: d.ID}}
}
func (s *Sub) rekeyDevice(d *deviceRow) {
s.a.db.Exec("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?", d.ID, -1)
}
// rotatePsk — новый PSK на каждом сервере; недоступные получат его, когда появятся.
func (s *Sub) rotatePsk(d *deviceRow) jsjson.Object {
s.a.db.Exec("UPDATE device_peers SET psk_pending = 1 WHERE device_id = ? AND peer_pub IS NOT NULL", d.ID)
var ids []int
for _, l := range s.linksOf(s.a.db, d.ID) {
ids = append(ids, l.ServerID)
}
s.reconcileAll(ids)
pending := 0
for _, l := range s.linksOf(s.a.db, d.ID) {
if l.PSKPending == 1 {
pending++
}
}
return jsjson.Object{{Key: "id", Val: d.ID}, {Key: "pending", Val: pending}}
}
func idObj(id int64) jsjson.Object { return jsjson.Object{{Key: "id", Val: id}} }
func merge(a, b jsjson.Object) jsjson.Object {
out := append(jsjson.Object{}, a...)
for _, kv := range b {
replaced := false
for i := range out {
if out[i].Key == kv.Key {
out[i].Val, replaced = kv.Val, true
}
}
if !replaced {
out = append(out, kv)
}
}
return out
}
// ── Роуты панели (/ui/masterkeys, /ui/devices) ──────────────────────────────
func (s *Sub) masterKeysRouter() *Router {
rt := newRouter()
auditOff := fail(409, "Аудит выключен на этой панели")
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) }
rt.Get("/", func(c *Ctx) bool {
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)}})
})
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) })
rt.Post("/audit", aw(func(c *Ctx) (int, any, error) {
if !s.auditOn {
return 0, nil, auditOff
}
s.auditAll(false)
return 200, s.auditState(), nil
}))
rt.Post("/audit/clean", aw(func(c *Ctx) (int, any, error) {
if !s.auditOn {
return 0, nil, auditOff
}
s.cleanNow()
return 200, s.auditState(), nil
}))
rt.Put("/audit/settings", aw(func(c *Ctx) (int, any, error) {
if !s.auditOn {
return 0, nil, auditOff
}
b := c.Obj()
s.auditMu.Lock()
next := s.auditSettings
s.auditMu.Unlock()
if v, has := b.Get("clean"); has {
bv, ok := v.(bool)
if !ok {
return 0, nil, fail(400, "clean: true или false")
}
next.Clean = bv
}
if v, has := b.Get("every_h"); has {
if !isInt(v) || v.(float64) < 1 || v.(float64) > 168 {
return 0, nil, fail(400, "Период: от 1 до 168 часов")
}
next.EveryH = int(v.(float64))
}
if v, has := b.Get("max"); has {
if !isInt(v) || v.(float64) < 1 || v.(float64) > 1000 {
return 0, nil, fail(400, "Предел: от 1 до 1000")
}
next.Max = int(v.(float64))
}
s.auditMu.Lock()
s.auditSettings = next
s.metaSet("audit_settings", next.json())
s.auditMu.Unlock()
return 200, s.auditState(), nil
}))
rt.Post("/", aw(func(c *Ctx) (int, any, error) {
out, err := s.createMaster(c.Body, nil)
return 201, out, err
}))
rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
out, err := s.updateMaster(m, c.Body, nil)
return 200, out, err
}))
rt.Post("/:id/rotate", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
s.rotateMaster(m)
return 200, idObj(m.ID), nil
}))
rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
s.rekeyMaster(m)
return 200, idObj(m.ID), nil
}))
rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
return 200, s.deleteMaster(m), nil
}))
rt.Get("/:id/link", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
out, err := s.linkOf(m, c)
return 200, out, err
}))
rt.Get("/:id/devices", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
}))
return rt
}
func (s *Sub) devicesRouter() *Router {
rt := newRouter()
dev := func(c *Ctx) (*deviceRow, error) { return s.deviceFor(idOf(c.Params["id"]), nil) }
rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) {
d, err := dev(c)
if err != nil {
return 0, nil, err
}
return 200, s.deleteDevice(d), nil
}))
rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) {
d, err := dev(c)
if err != nil {
return 0, nil, err
}
s.rekeyDevice(d)
return 200, idObj(d.ID), nil
}))
rt.Post("/:id/psk", aw(func(c *Ctx) (int, any, error) {
d, err := dev(c)
if err != nil {
return 0, nil, err
}
return 200, s.rotatePsk(d), nil
}))
return rt
}
// ── Внешний API: /api/v1/masterkeys (ключ awgk_) ────────────────────────────
// scopeOf — набор серверов API-ключа запроса. Устройства — вложенными путями, чтобы
// область проверялась через их мастер-ключ.
func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router {
rt := newRouter()
scope := func(c *Ctx) []int {
if sc := scopeOf(c); sc != nil {
return sc
}
return []int{}
}
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), scope(c)) }
device := func(c *Ctx) (*deviceRow, error) {
m, err := master(c)
if err != nil {
return nil, err
}
return s.deviceFor(idOf(c.Params["device"]), m)
}
withLink := func(c *Ctx, m *masterRow, base jsjson.Object) (jsjson.Object, error) {
l, err := s.linkOf(m, c)
if err != nil {
return nil, err
}
return merge(base, l), nil
}
rt.Get("/", aw(func(c *Ctx) (int, any, error) {
return 200, jsjson.Object{{Key: "keys", Val: s.listMasters(scope(c))}}, nil
}))
rt.Post("/", aw(func(c *Ctx) (int, any, error) {
if p := s.linkProblem(c); p != "" { // без ссылки ключ внешней программе бесполезен
return 0, nil, fail(503, p)
}
out, err := s.createMaster(c.Body, scope(c))
if err != nil {
return 0, nil, err
}
m, err := s.masterFor(int64(out.Get2("id").(int64)), nil)
if err != nil {
return 0, nil, err
}
res, err := withLink(c, m, out)
return 201, res, err
}))
rt.Get("/:id", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
// devices — счётчик, как в списке; сами устройства — в device_list.
out := s.masterOutByID(m.ID)
if s.linkProblem(c) != "" {
out = merge(out, jsjson.Object{{Key: "link", Val: nil}, {Key: "tls", Val: s.tlsPin != nil}})
} else if out, err = withLink(c, m, out); err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
if err != nil {
return 0, nil, err
}
return 200, merge(out, jsjson.Object{{Key: "device_list", Val: list}}), nil
}))
rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
out, err := s.updateMaster(m, c.Body, scope(c))
return 200, out, err
}))
rt.Post("/:id/rotate", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
s.rotateMaster(m)
m2, err := s.masterFor(m.ID, nil)
if err != nil {
return 0, nil, err
}
out, err := withLink(c, m2, idObj(m.ID))
return 200, out, err
}))
rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
s.rekeyMaster(m)
return 200, idObj(m.ID), nil
}))
rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
return 200, s.deleteMaster(m), nil
}))
rt.Get("/:id/devices", aw(func(c *Ctx) (int, any, error) {
m, err := master(c)
if err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
}))
rt.Delete("/:id/devices/:device", aw(func(c *Ctx) (int, any, error) {
d, err := device(c)
if err != nil {
return 0, nil, err
}
return 200, s.deleteDevice(d), nil
}))
rt.Post("/:id/devices/:device/rekey", aw(func(c *Ctx) (int, any, error) {
d, err := device(c)
if err != nil {
return 0, nil, err
}
s.rekeyDevice(d)
return 200, idObj(d.ID), nil
}))
rt.Post("/:id/devices/:device/psk", aw(func(c *Ctx) (int, any, error) {
d, err := device(c)
if err != nil {
return 0, nil, err
}
return 200, s.rotatePsk(d), nil
}))
return rt
}
// ── Удаление сервера из реестра ─────────────────────────────────────────────
// beforeServerDelete — пока сервером пользуется живой мастер-ключ — нельзя. Иначе сперва
// сводим его (пиры панели снимутся) — после удаления до него уже не дотянуться. Не вышло
// (нода не в сети) — удалять только с force.
func (s *Sub) beforeServerDelete(id int, force bool) *deleteVeto {
live := strs(s.a.db, "SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NULL", id)
if len(live) > 0 {
return &deleteVeto{Error: "Сервер в мастер-ключах: " + strings.Join(live, ", ") + " — сначала уберите его оттуда"}
}
if s.a.nodes.IsOnline(id) {
s.reconcile(id)
}
waiting := strs(s.a.db, "SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NOT NULL", id)
tombs := s.tombCount(id)
if len(waiting) == 0 && tombs == 0 || force {
return nil
}
msg := "Сервер не подтвердил, что снял пиров"
if len(waiting) > 0 {
msg += " удалённых мастер-ключей (" + strings.Join(waiting, ", ") + ")"
}
msg += ". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)"
return &deleteVeto{Error: msg, Forcible: true}
}
func (s *Sub) afterServerDelete(id int) {
s.a.db.Exec("DELETE FROM master_servers WHERE server_id = ?", id)
s.a.db.Exec("DELETE FROM master_keys WHERE revoked_at IS NOT NULL AND id NOT IN (SELECT master_id FROM master_servers)")
}
+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