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

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

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

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

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

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

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

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

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

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

- tools/diffbench/compare.mjs: decodes vpn:// (Node and Go zlib differ), masks
  live host numbers, Express dev-mode stack traces, temp file names and the
  order of calls from concurrent background work
- tools/diffbench/local.sh: the same run without Docker, stubs only on the
  run's own PATH (for disposable machines)
- run.sh: wait for the background shaping apply before stopping the service,
  otherwise the stub log is cut short
2026-10-14 13:27:24 +00:00
maeneko 5687ba0d3d Drop awg-ui/package-lock.json added by mistake 2026-10-14 13:27:24 +00:00
maeneko 016d3de62d Port awg-agent to Go: awg-control agent
- internal/agent: outbound WSS to core with SPKI pin checked inside the TLS
  handshake, Ed25519 challenge-response, one-shot join secret (dropped from
  memory and cli.env after the first ready), forwarding of core requests to the
  local awg-ctrl with an internal token, dead-connection timer on pings,
  backoff up to a minute (a minute after 4003)
- path filter is stricter than the Node one where it relied on WHATWG URL
  normalisation: backslashes, a leading "//" and %2e dot segments are rejected
- tests: end-to-end against a fake TLS core and a fake awg-ctrl; also checked
  by hand against the real awg-ui/nodes.ts hub
- CI: Go build, vet and race tests on GitHub and Gitea
2026-10-14 13:27:24 +00:00
maeneko 59e076c1de Start the Go rewrite: jsjson, awg-ctrl port, differential bench
- internal/jsjson: JSON.stringify-compatible encoder and order-preserving decoder,
  tested against fixtures produced by Node itself
- internal/ctrl: awg-ctrl in Go (params, vpn:// keys, users, peers, sync, 3.1
  upgrade, CAKE shaping, metrics, monitor job, Express-compatible HTTP layer)
- internal/iauth, internal/logx: internal Ed25519 tokens and logging
- tools/diffbench: Docker bench with stubbed awg/ip/tc/nft that runs one scenario
  against the TS and Go implementations for comparison
2026-10-14 13:27:24 +00:00
maeneko 53d5a08bbd Add shaping, Prometheus, monitor tab and peer audit
Baseline TypeScript version before the Go rewrite: CAKE fair sharing with
automatic deprioritisation of heavy devices, per-key priority, /metrics with
a metrics token, the built-in Prometheus install/remove, the Monitor tab,
grouped sidebar tabs and the dead-peer audit.
2026-10-14 13:27:23 +00:00
maeneko 7fbff82e76 Bump to Beta 0.3.4
ci / check (push) Successful in 6m20s
release / release (push) Successful in 3m43s
All seven places the version is written down: .env, the copy in
install.sh, the four package.json files and the /ui/brand example in the
README.

The panel shows each server's Forgetting version; master keys get a
stable uuid; lowering a key's device limit unbinds the newest devices.
2026-09-28 14:12:27 +03:00
maeneko 565b2759ed Unbind the newest devices when a master key's limit drops
Lowering device_limit below the number of bound devices now forgets the
extra ones right away, newest first, so the devices that took their
slots first keep them. Their peers are removed and they get 410 like a
revoke from the panel. PATCH returns the unbound ids; the panel names
the devices that will be dropped and asks before saving.
2026-09-28 14:12:27 +03:00
maeneko f0f2f9848a Give master keys a stable uuid
- master_keys.uuid (UUID v4, unique index): new keys get one on create,
  existing keys once on start. It survives rotate (new secret) and
  relabel, so it identifies the key itself rather than its link.
- /ui/masterkeys* and /api/v1/masterkeys* return uuid, and :id in those
  paths accepts the numeric id or the uuid.
- /sub/v1 register, peek and devices return key_uuid, so a client can
  recognise the same key behind a reissued link. It is not in the sen://
  link (that would need a new format) and not in config, so rev and
  existing clients are unaffected - checked against a 0.3.1 ui.db.
- Panel: the uuid sits in the key's row like a device ID (full value in
  the tooltip, click copies) and in the key settings with a copy button;
  on mobile it is shown under the key's name.
- README: uuid in the master key API.
2026-09-27 18:04:05 +03:00
maeneko 5c3bf21918 Show each server's Forgetting version in the panel
- awg-agent sends its install's VERSION (from the CLI's env, else the
  project's .env) in the auth frame; the hub keeps it in nodes.version
  (added by migration), so an offline node still shows the last one.
- GET /ui/nodes returns version per server (0 is the panel's own) and the
  panel's version at the top.
- ServerBar: a vX.Y.Z chip next to the AWG generation. A node older or
  newer than the panel, or one that sends no version (agent before
  0.3.4, shown as v?.?.?), gets a warm chip with an "update it" tooltip.
- App.css: tertiary-container tokens from design.md (light) and a warm
  dark pair; they did not exist yet.
- Mock server: panel 0.3.3, Frankfurt behind on 0.3.2, Amsterdam with no
  version.
2026-09-27 18:03:57 +03:00
123 changed files with 21841 additions and 264 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.3
VERSION=0.4.5
+12
View File
@@ -26,6 +26,18 @@ jobs:
- name: Installer syntax
run: bash -n install.sh
# Go-переписывание (awg-control): сборка, vet и тесты. composites выключен —
# jsjson.KV{k, v} без имён полей написан так намеренно.
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Go build, vet, test
run: |
go build ./...
go vet -composites=false ./...
go test -race ./...
- name: awg-ctrl types
working-directory: awg-ctrl
run: |
+14 -12
View File
@@ -1,5 +1,5 @@
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает тот
# же архив, но заливает его через API Gitea — gh CLI в раннере нет.
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает те
# же архивы, но заливает его через API Gitea — gh CLI в раннере нет.
#
# Gitea читает workflow из .gitea/workflows; когда этот каталог есть, файлы из
# .github/workflows она не запускает, так что GitHub-версия остаётся только для
@@ -39,12 +39,13 @@ jobs:
npm install
npm run build
- name: Package archive
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Архив на архитектуру: Go-бинарник awg-control + собранный фронтенд + .env.
- name: Package archives
run: sh tools/release/package.sh "${GITHUB_REF_NAME#v}"
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
# зависит от версии движка выражений в Gitea.
@@ -58,7 +59,6 @@ jobs:
[ -n "$TOKEN" ] || { echo "нет токена: ни RELEASE_TOKEN, ни GITEA_TOKEN"; exit 1; }
VERSION="${GITHUB_REF_NAME#v}"
ARCHIVE="awgcontrol-${VERSION}.tar.gz"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
# id релиза — первое поле в JSON Gitea, поэтому берём ПЕРВОЕ вхождение
@@ -82,8 +82,10 @@ jobs:
fi
[ -n "$id" ] || { echo "не удалось получить id релиза"; exit 1; }
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
-H "Authorization: token $TOKEN" \
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
for ARCHIVE in awgcontrol-"${VERSION}"-linux-*.tar.gz; do
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
-H "Authorization: token $TOKEN" \
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
done
echo "опубликовано: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
+12
View File
@@ -23,6 +23,18 @@ jobs:
- name: Installer syntax
run: bash -n install.sh
# Go-переписывание (awg-control): сборка, vet и тесты. composites выключен —
# jsjson.KV{k, v} без имён полей написан так намеренно.
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Go build, vet, test
run: |
go build ./...
go vet -composites=false ./...
go test -race ./...
- name: awg-ctrl types
working-directory: awg-ctrl
run: |
+18 -9
View File
@@ -4,6 +4,12 @@ on:
push:
tags:
- 'v*'
# Ручной запуск: тег создаётся при публикации на коммите, с которого запущен workflow.
workflow_dispatch:
inputs:
tag:
description: 'Тег релиза, например v0.4.0'
required: true
permissions:
contents: write
@@ -11,6 +17,8 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
steps:
- uses: actions/checkout@v4
@@ -24,17 +32,18 @@ jobs:
npm install
npm run build
- name: Package archive
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Архив на архитектуру: Go-бинарник awg-control + собранный фронтенд + .env.
- name: Package archives
run: sh tools/release/package.sh "${TAG#v}"
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
--title "${GITHUB_REF_NAME}" --generate-notes --prerelease
VERSION="${TAG#v}"
gh release create "${TAG}" awgcontrol-"${VERSION}"-linux-*.tar.gz \
--target "${GITHUB_SHA}" --title "${TAG}" --generate-notes --prerelease
+4 -1
View File
@@ -20,4 +20,7 @@ yarn-error.log*
# Все markdown, кроме README.md
*.md
!README.md
!README.md
# Go: бинарники и прогоны дифференциального стенда
tools/diffbench/bin/
tools/diffbench/out/
+23 -13
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» в панели —
@@ -104,8 +106,8 @@ curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
| Метод | Что делает |
|---|---|
| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) |
| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, label, device_limit, devices, servers, created_at, deleting? }] }` |
| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, uuid, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) |
| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`. `uuid` — постоянный идентификатор ключа: не меняется ни при перевыпуске ссылки, ни при правке метки. Везде, где в пути `:id`, можно передать и `uuid` |
| `GET /api/v1/masterkeys/:id` | Ключ, ссылка и устройства одним запросом: те же поля + `link`, `tls`, `device_list` (`devices` — счётчик) |
| `PATCH /api/v1/masterkeys/:id` | `{ label?, device_limit?, servers? }` — смена серверов сразу добавляет/снимает пиров устройств |
| `POST /api/v1/masterkeys/:id/rotate` | Новая ссылка `{ id, link, tls }`; старая не принимает новые устройства, подключённые работают дальше |
@@ -149,7 +151,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.3" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.5" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
@@ -196,8 +198,9 @@ base64url-декодировать, отбросить первые 4 байта
| Метод | Что делает |
|---|---|
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, label, device_limit, devices, servers, created_at, deleting? }] }` |
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` |
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }], defaults }`; `:id` в путях — id или `uuid`; `defaults` — серверы новых ключей или `null` |
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201`; без `servers` — `defaults`, а нет их — сервер по умолчанию |
| `POST /ui/masterkeys/servers` | «Серверы для всех»: `{ add?, remove?, defaults?, dry_run? }` — `add` появляются у всех живых ключей, `remove` снимаются у всех, прочие серверы ключей не трогаются; `defaults` (`null` — сбросить) — набор новых ключей. → `{ changed, total, hidden: [{ id, label, keys }] }`, `hidden` — ключи API, которые перестанут видеть столько мастер-ключей (сервер вне их области). `409`, если у ключа не останется серверов |
| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
@@ -209,7 +212,14 @@ base64url-декодировать, отбросить первые 4 байта
## Управление
```bash
awg-ctrl
awg-ctrl # меню
awg-ctrl start|stop|restart|status
awg-ctrl credentials [user|pass]
awg-ctrl join '<awgjoin://…>' [--no-panel] / unjoin
awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес
awg-ctrl migrate export [файл] # перенос панели на отдельный сервер без VPN
awg-ctrl migrate import <архив> # (порядок — docs/node-protocol.md, «Перенос панели»)
awg-ctrl migrate cancel
```
Через systemd (автозапуск на загрузке):
+11
View File
@@ -23,6 +23,16 @@ const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnez
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения
// Версия Forgetting на этой ноде — панель показывает её в карточке сервера. CLI передаёт её из
// .env в корне проекта; при запуске вручную читаем тот же .env сами.
function envFromFile(file: string, key: string): string {
try {
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
return m ? m[1].trim() : "";
} catch { return ""; }
}
const VERSION = process.env.VERSION || envFromFile(path.join(__dirname, "..", ".env"), "VERSION");
if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) {
console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`");
process.exit(1);
@@ -138,6 +148,7 @@ function connectOnce(): Promise<{ authFailed: boolean }> {
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
};
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
if (VERSION) auth.version = VERSION;
ws.send(JSON.stringify(auth));
} else if (m.t === "ready") {
ready = true; backoff = 1000; armDead();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-agent",
"version": "0.3.3",
"version": "0.4.5",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
+240 -17
View File
@@ -1,15 +1,16 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, closeSync } from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import { execSync, spawnSync } from "child_process";
import { execSync, spawnSync, execFile, spawn } from "child_process";
import * as zlib from "zlib";
import express, { Request, Response, NextFunction } from "express";
import Database from "better-sqlite3";
import winston from "winston";
import { createShaping } from "./shaping";
const logsDir = path.join(process.cwd(), "logs");
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
@@ -575,6 +576,26 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
} catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; }
}
// Шейпинг (CAKE) и сэмплер тяжёлых устройств. Счётчики читаем асинхронно: цикл
// не должен блокировать event loop, как это делают остальные вызовы awg.
const shaping = createShaping({
iface: CONFIG.interface,
getCfg, setCfg, logger,
// CAKE видит только внутренний IP-пакет, на проводе к нему добавляются
// IPv4 (20) + UDP (8) + заголовок WG (32) = 60 байт и случайный префикс S4.
// Формулу сверять iperf3-замером на стенде (SHAPE_OVERHEAD перекрывает её).
overhead: () => 60 + (Number(CONFIG.awgParams.S4) || 0),
peers: () => new Promise((resolve, reject) => {
execFile("awg", ["show", CONFIG.interface, "dump"], { encoding: "utf8", timeout: 10_000 }, (err, out) => {
if (err) { reject(err); return; }
const rows = out.split("\n").slice(1).map(l => l.split("\t"))
.filter(f => f[0] && f[3])
.map(f => ({ ip: f[3].split(",")[0].split("/")[0], rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 }));
resolve(rows);
});
}),
});
function rebuildConf() {
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
const devices = stmts.peersAll.all();
@@ -680,6 +701,7 @@ function restartAwg() {
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
const gen = reloadAwgParams();
syncPeers();
void shaping.apply(); // tc-правила жили на интерфейсе, который мы только что пересоздали
const versions = readAwgVersions();
CONFIG.awgModule = versions.module;
CONFIG.awgTools = versions.tools;
@@ -803,6 +825,7 @@ function startInterface() {
const r = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`);
syncPeers();
void shaping.apply();
return getInterfaceStatus();
}
@@ -849,12 +872,30 @@ function addUser(username: string): UserRow {
};
}
// Снять пира с интерфейса и убедиться, что его там нет. Раньше результат `awg set … remove`
// не проверялся: строку из базы удаляли, а пир в ядре оставался живым, и про него уже никто не
// знал. Теперь порядок такой: сначала ядро, потом база — при отказе строка остаётся, и
// повторная попытка (надгробие панели, следующий sync) найдёт пира.
function kernelPeers(): Set<string> | null {
const r = spawnSync("awg", ["show", CONFIG.interface, "peers"]);
return r.status === 0 ? new Set(r.stdout.toString().split("\n").map(l => l.trim()).filter(Boolean)) : null;
}
function dropFromKernel(pub: string): boolean {
const r = spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
if (r.status === 0) return true;
const left = kernelPeers();
if (left && !left.has(pub)) return true; // ответ странный, но пира уже нет
logger.error("peer remove from kernel failed", { pub, stderr: r.stderr?.toString() });
return false;
}
function removeUser(username: string) {
const user = stmts.get.get(username);
if (!user) throw new Error("Пользователь не найден");
if (!dropFromKernel(user.pub_key)) throw new Error("Не удалось снять пира с интерфейса");
stmts.delete.run(username);
spawnSync("awg", ["set", CONFIG.interface, "peer", user.pub_key, "remove"]);
rebuildConf();
for (const ext of [".conf", ".key"]) {
@@ -920,7 +961,9 @@ function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: str
stmts.peerRekey.run(oldPub, newPub);
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
}
spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]);
// Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа не откатываем:
// новый уже стоит, а клиент к этому моменту получил бы ошибку.
if (!dropFromKernel(oldPub)) logger.error("peer rekey: старый ключ остался в ядре", { owner: old.owner });
rebuildConf();
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
return { ip: old.ip, psk_key: old.psk_key };
@@ -941,8 +984,8 @@ function rotatePeerPsk(pub: string): { ip: string; psk_key: string } {
function removePeer(pub: string) {
const peer = stmts.peerGet.get(pub);
if (!peer) throw new HttpError(404, "Пир не найден");
if (!dropFromKernel(pub)) throw new Error("Не удалось снять пира с интерфейса");
stmts.peerDelete.run(pub);
spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
rebuildConf();
logger.info("peer removed", { owner: peer.owner });
}
@@ -964,18 +1007,15 @@ function syncOwnedPeers(panel: string, keep: string[]): {
const prefix = panel + "/";
const removed: PeerRow[] = [];
let claimed = 0;
db.transaction(() => {
for (const p of stmts.peersAll.all()) {
if (p.owner.startsWith(prefix)) {
if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
claimed++;
}
for (const p of stmts.peersAll.all()) {
if (p.owner.startsWith(prefix)) {
// Ядро первым: не снялся — строка остаётся, следующий sync попробует снова.
if (!want.has(p.pub_key) && dropFromKernel(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
claimed++;
}
})();
for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]);
}
if (removed.length || claimed) rebuildConf();
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
@@ -992,7 +1032,9 @@ const app = express();
const SYNC_PATH = "/api/peers/sync";
const jsonSmall = express.json({ limit: "1kb" });
const jsonSync = express.json({ limit: "512kb" });
app.use((req, res, next) => (req.path === SYNC_PATH ? jsonSync : jsonSmall)(req, res, next));
// /api/audit/clean тоже несёт до SYNC_MAX ключей — под 1kb он принимал бы около двадцати.
const BIG_BODY_PATHS = new Set([SYNC_PATH, "/api/audit/clean"]);
app.use((req, res, next) => (BIG_BODY_PATHS.has(req.path) ? jsonSync : jsonSmall)(req, res, next));
function verifyInternalToken(token: string): boolean {
try {
@@ -1075,6 +1117,8 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
rx: peers[u.pub_key]?.rx ?? 0,
tx: peers[u.pub_key]?.tx ?? 0,
// «normal» | «background»: фоновый — пока канал занят, а устройство давно качает больше своей доли
priority: shaping.priorityOf(u.ip),
})),
});
}));
@@ -1111,10 +1155,43 @@ app.get("/api/peers", auth, handler((_req, res) => {
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
rx: stats[p.pub_key]?.rx ?? 0,
tx: stats[p.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(p.ip),
})),
});
}));
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять их
// после сбоя `awg set … remove` некому. Только чтение — панель (core) сама решает, что делать.
app.get("/api/audit", auth, handler((_req, res) => {
const r = spawnSync("awg", ["show", CONFIG.interface, "dump"]);
if (r.status !== 0) throw new HttpError(503, "awg show не ответил");
const known = new Set<string>([
...(db.prepare("SELECT pub_key FROM users").all() as { pub_key: string }[]).map(u => u.pub_key),
...stmts.peersAll.all().map(p => p.pub_key),
]);
const now = Math.floor(Date.now() / 1000);
const ghosts = r.stdout.toString().split("\n").slice(1).map(l => l.split("\t")).filter(f => f[0] && !known.has(f[0])).map(f => {
const lastHandshake = Number(f[4]) || 0;
return { pub_key: f[0], lastHandshake, online: lastHandshake > 0 && now - lastHandshake < 180, rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 };
});
res.json({ ghosts });
}));
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется: пир, который
// успел появиться в users/peers, не трогаем. Строк в базе у «призраков» нет — снимается только ядро.
app.post("/api/audit/clean", auth, handler((req, res) => {
const { pub_keys } = (req.body ?? {}) as { pub_keys?: unknown };
if (!Array.isArray(pub_keys) || pub_keys.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
const removed: string[] = [], skipped: string[] = [];
for (const k of pub_keys.map(checkPubKey)) {
const known = stmts.peerGet.get(k) || (db.prepare("SELECT 1 FROM users WHERE pub_key = ?").get(k));
if (known || k === getServerPublicKey() || !kernelPeers()?.has(k)) { skipped.push(k); continue; }
(dropFromKernel(k) ? removed : skipped).push(k);
}
if (removed.length) logger.warn("ghost peers removed from kernel", { count: removed.length, removed });
res.json({ removed, skipped });
}));
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
app.put(SYNC_PATH, auth, handler((req, res) => {
const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown };
@@ -1175,6 +1252,148 @@ app.post("/awg/upgrade", auth, handler((_req, res) => {
}
}));
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не пропустит
// (и без PATCH: агент отвечает на него 400).
app.get("/api/shaping", auth, handler(async (_req, res) => {
res.json(await shaping.status());
}));
app.put("/api/shaping", auth, handler(async (req, res) => {
const b = (req.body ?? {}) as Record<string, unknown>;
const mbit = (v: unknown, what: string): number => {
const n = Number(v);
if (!Number.isInteger(n) || n < 0 || n > 100_000) throw new HttpError(400, `${what}: целое число Мбит/с от 1 до 100000`);
return n;
};
if (typeof b.enabled !== "boolean" || typeof b.auto !== "boolean")
throw new HttpError(400, "enabled и auto должны быть true/false");
const downMbit = mbit(b.downMbit ?? 0, "Загрузка");
const upMbit = mbit(b.upMbit ?? 0, "Отдача");
if (b.enabled && (downMbit < 1 || upMbit < 1))
throw new HttpError(400, "Для включения укажите ширину канала в обе стороны");
try {
await shaping.update({ enabled: b.enabled, auto: b.auto, downMbit, upMbit });
} catch (e) {
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
throw new HttpError(500, e instanceof Error ? e.message : "Не удалось применить шейпинг");
}
res.json(await shaping.status());
}));
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с —
// несколько скрейперов не должны гонять `awg show` чаще.
const METRICS_TTL_MS = 5_000;
let metricsCache: { at: number; data: Promise<unknown> } | null = null;
function linkCounter(name: string): number {
try { return Number(readFileSync(`/sys/class/net/${CONFIG.interface}/statistics/${name}`, "utf8")) || 0; }
catch { return 0; }
}
// Нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у node_exporter): долю считает
// Prometheus через rate(), здесь мгновенного «процента» нет. Память — MemAvailable, а не MemFree: кэш ядра свободен.
const CPU_MODES = ["user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"] as const;
function readHost() {
try {
const first = readFileSync("/proc/stat", "utf8").split("\n")[0].trim().split(/\s+/).slice(1).map(Number);
const cpu: Record<string, number> = {};
CPU_MODES.forEach((m, i) => { cpu[m] = (first[i] || 0) / 100; }); // USER_HZ = 100 на всех обычных ядрах
const mem = readFileSync("/proc/meminfo", "utf8");
const kb = (k: string) => Number(mem.match(new RegExp(`^${k}:\\s+(\\d+) kB`, "m"))?.[1] ?? 0) * 1024;
return {
cpu, cores: os.cpus().length, load: os.loadavg(),
mem: { total: kb("MemTotal"), available: kb("MemAvailable") },
};
} catch { return null; }
}
async function collectMetrics() {
const st = getInterfaceStatus();
const stats = st.up ? getPeersData() : {};
const list: { kind: "user" | "device"; name: string; ip: string; online: boolean; rx: number; tx: number; priority: string }[] = [
...(db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[]).map(u => ({
kind: "user" as const, name: u.name, ip: u.ip,
online: stats[u.pub_key]?.online ?? false, rx: stats[u.pub_key]?.rx ?? 0, tx: stats[u.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(u.ip),
})),
...stmts.peersAll.all().map(p => ({
kind: "device" as const, name: "", ip: p.ip,
online: stats[p.pub_key]?.online ?? false, rx: stats[p.pub_key]?.rx ?? 0, tx: stats[p.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(p.ip),
})),
];
const all = Object.values(stats);
return {
up: st.up,
gen: CONFIG.gen,
peers: { total: st.peers, online: all.filter(p => p.online).length },
// С точки зрения сервера: rx — принято от клиентов (их отдача), tx — отправлено клиентам (их загрузка).
rx_bytes: all.reduce((a, p) => a + p.rx, 0),
tx_bytes: all.reduce((a, p) => a + p.tx, 0),
link: {
rx_bytes: linkCounter("rx_bytes"), tx_bytes: linkCounter("tx_bytes"),
rx_packets: linkCounter("rx_packets"), tx_packets: linkCounter("tx_packets"),
rx_dropped: linkCounter("rx_dropped"), tx_dropped: linkCounter("tx_dropped"),
rx_errors: linkCounter("rx_errors"), tx_errors: linkCounter("tx_errors"),
},
shaping: await shaping.status(),
host: readHost(),
list,
};
}
app.get("/api/metrics", auth, handler(async (_req, res) => {
if (!metricsCache || Date.now() - metricsCache.at > METRICS_TTL_MS) {
const data = collectMetrics();
metricsCache = { at: Date.now(), data };
data.catch(() => { metricsCache = null; }); // ошибку не кэшируем
}
res.json(await metricsCache.data);
}));
// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие одно из двух и без
// параметров: awg-ctrl запускает ту же команду, что оператор набрал бы руками (`awg-ctrl monitor install|remove`,
// root), так что код один. Панель сама пакетов не ставит и не удаляет — она только просит об этом здесь.
const MONITOR_CLI = process.env.AWG_CLI ?? "/usr/local/bin/awg-ctrl";
const MONITOR_LOG = process.env.MONITOR_LOG ?? path.resolve(process.cwd(), "..", "logs", "monitor-job.log");
type MonitorAction = "install" | "remove";
let monitorJob: { action: MonitorAction; startedAt: number; exit: number | null } | null = null;
const logTail = (): string => {
try {
const t = readFileSync(MONITOR_LOG, "utf8").slice(-4000);
// apt рисует прогресс возвратом каретки и цветами — в панели это мусор
return t.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\r/g, "\n");
} catch { return ""; }
};
function startMonitorJob(action: MonitorAction, res: Response) {
if (monitorJob && monitorJob.exit === null) { res.status(409).json({ error: "Операция уже идёт" }); return; }
if (!existsSync(MONITOR_CLI)) throw new HttpError(501, `CLI не найден (${MONITOR_CLI}): выполните на сервере awg-ctrl monitor ${action}`);
mkdirSync(path.dirname(MONITOR_LOG), { recursive: true });
const fd = openSync(MONITOR_LOG, "w");
const job = { action, startedAt: Date.now(), exit: null as number | null };
monitorJob = job;
const done = (code: number) => { job.exit = code; try { closeSync(fd); } catch { /* уже закрыт */ } };
const child = spawn(MONITOR_CLI, ["monitor", action], { stdio: ["ignore", fd, fd] });
child.on("error", e => { logger.error("monitor: не запустился", { action, error: e.message }); done(127); });
child.on("exit", code => { logger.info("monitor: завершён", { action, code }); done(code ?? 1); });
res.status(202).json({ running: true, action });
}
app.post("/api/monitor/install", auth, handler((_req, res) => { startMonitorJob("install", res); }));
app.post("/api/monitor/remove", auth, handler((_req, res) => { startMonitorJob("remove", res); }));
app.get("/api/monitor/job", auth, handler((_req, res) => {
res.json({
action: monitorJob?.action ?? null,
running: !!monitorJob && monitorJob.exit === null,
exit: monitorJob?.exit ?? null,
log: monitorJob ? logTail() : "",
});
}));
app.get("/awg/status", auth, handler((_req, res) => {
res.json(getInterfaceStatus());
}));
@@ -1188,3 +1407,7 @@ app.listen(SERVER.port, "127.0.0.1", () => {
});
ensureInterfaceUp();
syncPeers();
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
void shaping.apply();
shaping.start();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.3.3",
"version": "0.4.5",
"description": "",
"main": "dist/index.js",
"scripts": {
+338
View File
@@ -0,0 +1,338 @@
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
//
// Схема:
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
//
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому apply() зовётся
// на старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
//
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по
// времени, ни по неактивности (правило проекта: удаление только по явному списку
// от панели).
import { execFile, spawn } from "child_process";
import type { Logger } from "winston";
export interface ShapingSettings {
enabled: boolean;
downMbit: number;
upMbit: number;
auto: boolean;
}
export interface PeerCounters {
ip: string;
rx: number; // принято от клиента (его отдача)
tx: number; // отправлено клиенту (его загрузка)
}
export interface ShapingDeps {
iface: string;
getCfg: (key: string, fallback: string) => string;
setCfg: (key: string, value: string) => void;
logger: Pick<Logger, "info" | "warn" | "error">;
// Байт на пакет, которые туннель добавляет на проводе (CAKE видит только внутренний IP-пакет).
overhead: () => number;
peers: () => Promise<PeerCounters[]>;
}
export interface QdiscStats { packets: number; bytes: number; drops: number; backlog: number }
export interface ShapingStatus {
settings: ShapingSettings;
active: boolean; // CAKE реально стоит на интерфейсе
congested: boolean;
heavy: string[]; // IP устройств в bulk-классе
devices: number; // активных устройств в окне
rateDown: number; // Мбит/с за последний тик
rateUp: number;
down?: QdiscStats;
up?: QdiscStats;
}
// Окна и пороги. Переопределение через env — только для тестов на стенде.
const num = (v: string | undefined, d: number) => (v && Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
const SAMPLE_S = num(process.env.SHAPE_SAMPLE_S, 10);
const WINDOW_S = num(process.env.SHAPE_WINDOW_S, 600);
const CONGEST_ON = 0.85; // доля ширины, выше которой канал считается перегруженным
const CONGEST_OFF = 0.70; // и ниже которой перегрузка снимается (гистерезис)
const ACTIVE_MBIT = 0.1; // меньше — устройство не считается активным
const HEAVY_MULT = 1.5; // тяжёлое: ≥ 1.5 честных долей…
const HEAVY_MIN_FRAC = 0.05; // …и не меньше 5% ширины канала
const CHECK_EVERY = 3; // страховочная проверка qdisc — раз в N тиков
const IFB = "ifb-awg";
const NFT_TABLE = "awg_shape";
function exec(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { encoding: "utf8", timeout: 15_000 }, (err, stdout, stderr) => {
if (err) reject(new Error(`${cmd} ${args.join(" ")}: ${(stderr || err.message).toString().trim()}`));
else resolve(stdout);
});
});
}
function execStdin(cmd: string, args: string[], input: string): Promise<void> {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ["pipe", "ignore", "pipe"] });
let err = "";
p.stderr.on("data", d => { err += d; });
p.stdin.on("error", () => undefined); // процесс умер раньше, чем дочитал stdin — причина придёт в close
p.on("error", reject);
p.on("close", code => code === 0 ? resolve() : reject(new Error(`${cmd}: ${err.trim() || `exit ${code}`}`)));
p.stdin.end(input);
});
}
interface Tick { dt: number; bytes: number }
export function createShaping(deps: ShapingDeps) {
const { iface, logger } = deps;
// ───────────── настройки ─────────────
function settings(): ShapingSettings {
return {
enabled: deps.getCfg("shapeEnabled", "0") === "1",
downMbit: Number(deps.getCfg("shapeDownMbit", "0")) || 0,
upMbit: Number(deps.getCfg("shapeUpMbit", "0")) || 0,
auto: deps.getCfg("shapeAuto", "1") === "1",
};
}
function save(s: ShapingSettings) {
deps.setCfg("shapeEnabled", s.enabled ? "1" : "0");
deps.setCfg("shapeDownMbit", String(s.downMbit));
deps.setCfg("shapeUpMbit", String(s.upMbit));
deps.setCfg("shapeAuto", s.auto ? "1" : "0");
}
// ───────────── применение ─────────────
// Все изменения tc/nft идут строго по одному: два параллельных apply() дали бы
// полусобранные qdisc.
let chain: Promise<unknown> = Promise.resolve();
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
const r = chain.then(fn, fn);
chain = r.catch(() => undefined);
return r;
};
let lastError = "";
const noteError = (e: unknown) => {
const m = e instanceof Error ? e.message : String(e);
if (m !== lastError) logger.warn("shaping: " + m); // одинаковую ошибку в лог не спамим
lastError = m;
};
const cakeOverhead = () => {
const o = Number(process.env.SHAPE_OVERHEAD);
return Number.isFinite(o) && o >= 0 && process.env.SHAPE_OVERHEAD ? o : deps.overhead();
};
async function nftApply(heavy: string[]) {
const elements = heavy.length ? `\n elements = { ${heavy.join(", ")} }` : "";
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
await execStdin("nft", ["-f", "-"], `
add table inet ${NFT_TABLE}
delete table inet ${NFT_TABLE}
table inet ${NFT_TABLE} {
set heavy {
type ipv4_addr${elements}
}
chain postrouting {
type filter hook postrouting priority -150; policy accept;
oifname "${iface}" ip daddr @heavy ip dscp set cs1
}
}
`);
}
async function hasCake(dev: string): Promise<boolean> {
try { return /\bcake\b/.test(await exec("tc", ["qdisc", "show", "dev", dev, "root"])); }
catch { return false; }
}
async function hasIngressRedirect(): Promise<boolean> {
try { return /mirred/.test(await exec("tc", ["filter", "show", "dev", iface, "parent", "ffff:"])); }
catch { return false; }
}
async function applyNow() {
const s = settings();
if (!s.enabled || s.downMbit <= 0 || s.upMbit <= 0) { await removeNow(); return; }
const oh = cakeOverhead();
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
await exec("tc", ["qdisc", "replace", "dev", iface, "root", "cake",
"bandwidth", `${s.downMbit}mbit`, "diffserv3", "dual-dsthost", "overhead", String(oh)]);
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
try { await exec("ip", ["link", "show", IFB]); }
catch { await exec("ip", ["link", "add", IFB, "type", "ifb"]); }
await exec("ip", ["link", "set", IFB, "up"]);
await exec("tc", ["qdisc", "replace", "dev", IFB, "root", "cake",
"bandwidth", `${s.upMbit}mbit`, "besteffort", "dual-srchost", "overhead", String(oh)]);
if (!(await hasIngressRedirect())) {
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
await exec("tc", ["qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"]);
await exec("tc", ["filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
"matchall", "action", "mirred", "egress", "redirect", "dev", IFB]);
}
await nftApply([...heavySet]);
lastError = "";
logger.info("shaping applied", { down: s.downMbit, up: s.upMbit, overhead: oh, heavy: heavySet.size });
}
async function removeNow() {
await exec("tc", ["qdisc", "del", "dev", iface, "root"]).catch(() => undefined);
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
await exec("ip", ["link", "del", IFB]).catch(() => undefined);
await exec("nft", ["delete", "table", "inet", NFT_TABLE]).catch(() => undefined);
heavySet.clear(); history.clear(); totals.length = 0; congested = false;
}
// Не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от ensureInterfaceUp).
function apply(): Promise<void> {
return serial(applyNow).catch(noteError);
}
async function update(s: ShapingSettings): Promise<void> {
save(s);
await serial(applyNow); // пользователю нужна честная ошибка, а не молчаливый warn
lastError = "";
}
// ───────────── сэмплер (RFC 6057) ─────────────
const prev = new Map<string, { rx: number; tx: number }>();
const history = new Map<string, Tick[]>(); // загрузка по IP
const totals: Tick[] = []; // суммарная загрузка (всё, что ушло клиентам)
const heavySet = new Set<string>();
let congested = false;
let lastRateDown = 0, lastRateUp = 0;
let lastTickAt = 0;
let tickNo = 0;
const windowTicks = Math.max(3, Math.round(WINDOW_S / SAMPLE_S));
const push = (arr: Tick[], t: Tick) => { arr.push(t); while (arr.length > windowTicks) arr.shift(); };
const mbit = (arr: Tick[]) => {
const dt = arr.reduce((a, t) => a + t.dt, 0);
return dt > 0 ? arr.reduce((a, t) => a + t.bytes, 0) * 8 / dt / 1e6 : 0;
};
async function tick() {
const s = settings();
if (!s.enabled) return;
const now = Date.now();
const dt = lastTickAt ? (now - lastTickAt) / 1000 : SAMPLE_S;
lastTickAt = now;
tickNo++;
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
if (tickNo % CHECK_EVERY === 0 && !(await hasCake(iface))) {
logger.warn("shaping: qdisc на интерфейсе пропал, применяю заново");
await apply();
}
let peers: PeerCounters[];
try { peers = await deps.peers(); } catch (e) { noteError(e); return; }
let totalDown = 0, totalUp = 0;
const seen = new Set<string>();
for (const p of peers) {
seen.add(p.ip);
const before = prev.get(p.ip);
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира → отрицательная дельта = 0.
const dTx = before && p.tx >= before.tx ? p.tx - before.tx : 0;
const dRx = before && p.rx >= before.rx ? p.rx - before.rx : 0;
prev.set(p.ip, { rx: p.rx, tx: p.tx });
totalDown += dTx; totalUp += dRx;
let h = history.get(p.ip);
if (!h) history.set(p.ip, h = []);
push(h, { dt, bytes: dTx });
}
for (const ip of [...prev.keys()]) if (!seen.has(ip)) { prev.delete(ip); history.delete(ip); heavySet.delete(ip); }
push(totals, { dt, bytes: totalDown });
lastRateDown = totalDown * 8 / dt / 1e6;
lastRateUp = totalUp * 8 / dt / 1e6;
if (!s.auto) {
if (heavySet.size || congested) { heavySet.clear(); congested = false; await serial(() => nftApply([])).catch(noteError); }
return;
}
if (totals.length < windowTicks) return; // пока окно не набралось, решений не принимаем
const avg = mbit(totals);
const wasCongested = congested;
if (!congested && avg >= CONGEST_ON * s.downMbit) congested = true;
if ( congested && avg < CONGEST_OFF * s.downMbit) congested = false;
const nextHeavy = new Set<string>();
let active = 0;
if (congested) {
const rates = [...history.entries()].filter(([, h]) => h.length >= windowTicks).map(([ip, h]) => [ip, mbit(h)] as const);
active = rates.filter(([, r]) => r >= ACTIVE_MBIT).length;
const share = s.downMbit / Math.max(1, active);
for (const [ip, r] of rates) {
const wasHeavy = heavySet.has(ip);
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
if (r >= Math.max(HEAVY_MULT * share, HEAVY_MIN_FRAC * s.downMbit) || (wasHeavy && r >= share)) nextHeavy.add(ip);
}
}
const changed = nextHeavy.size !== heavySet.size || [...nextHeavy].some(ip => !heavySet.has(ip));
if (changed || wasCongested !== congested) {
heavySet.clear(); nextHeavy.forEach(ip => heavySet.add(ip));
logger.info("shaping: класс тяжёлых обновлён", { congested, avgMbit: Math.round(avg), heavy: [...heavySet] });
await serial(() => nftApply([...heavySet])).catch(noteError);
}
}
let timer: NodeJS.Timeout | null = null;
function start() {
if (timer) return;
let busy = false;
timer = setInterval(() => {
if (busy) return; // предыдущий тик ещё идёт — пропускаем, а не копим очередь
busy = true;
tick().catch(noteError).finally(() => { busy = false; });
}, SAMPLE_S * 1000);
timer.unref();
}
// ───────────── состояние ─────────────
async function qdiscStats(dev: string): Promise<QdiscStats | undefined> {
try {
const arr = JSON.parse(await exec("tc", ["-s", "-j", "qdisc", "show", "dev", dev, "root"])) as Record<string, unknown>[];
const q = arr.find(x => x.kind === "cake");
if (!q) return undefined;
return {
packets: Number(q.packets) || 0, bytes: Number(q.bytes) || 0,
drops: Number(q.drops) || 0, backlog: Number(q.backlog) || 0,
};
} catch { return undefined; }
}
async function status(): Promise<ShapingStatus> {
const s = settings();
const [active, down, up] = s.enabled
? await Promise.all([hasCake(iface), qdiscStats(iface), qdiscStats(IFB)])
: [false, undefined, undefined];
return {
settings: s, active, congested, heavy: [...heavySet],
devices: [...history.values()].filter(h => mbit(h) >= ACTIVE_MBIT).length,
rateDown: Math.round(lastRateDown * 10) / 10,
rateUp: Math.round(lastRateUp * 10) / 10,
down, up,
};
}
// Текущий приоритет устройства по его внутреннему IP: «фоновый» — пока он в bulk-классе.
const priorityOf = (ip: string): "normal" | "background" => (heavySet.has(ip) ? "background" : "normal");
return { settings, update, apply, start, status, priorityOf };
}
export type Shaping = ReturnType<typeof createShaping>;
+235
View File
@@ -0,0 +1,235 @@
// 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.
//
// Prometheus: GET /metrics на ядре. Метрики ядра и всех нод собираются здесь же —
// у нод через уже открытый WSS (awg-agent пропускает только /api/*, поэтому на самих
// нодах отдельного порта для скрейпа не нужно). Доступ — отдельный токен «только
// чтение метрик»; он не открывает ни /api/v1, ни панель.
import crypto from "crypto";
import fs from "fs";
import { Router, type Request, type Response, type NextFunction } from "express";
import type Database from "better-sqlite3";
interface ServerInfo {
id: number; name: string; local: boolean; online: boolean; pending: boolean;
version: string | null;
health: { gen: string; module: string; tools: string } | null;
}
interface MetricsDeps {
uidb: Database.Database;
version: string;
servers: {
list: () => ServerInfo[];
isOnline: (id: number) => boolean;
ctrlFor: (id: number) => (method: string, urlPath: string) => Promise<{ status: number; data: any }>;
};
}
const SCRAPE_DEADLINE_MS = 5_000;
const TOUCH_EVERY_S = 60; // last_used пишем не чаще раза в минуту, а не на каждый скрейп
const hashToken = (t: string) => crypto.createHash("sha256").update(t).digest();
// Токен встроенного Prometheus (`awg-ctrl monitor install`): сам токен лежит только у Prometheus,
// здесь — его хэш. Файл читаем при каждой проверке, пересчитывая по mtime: установка не требует перезапуска панели.
const LOCAL_HASH_FILE = process.env.MONITOR_HASH_FILE ?? "/etc/amnezia/amneziawg/monitor_token.sha256";
let localHash: { mtime: number; hash: Buffer | null } = { mtime: -1, hash: null };
function localTokenHash(): Buffer | null {
try {
const mtime = fs.statSync(LOCAL_HASH_FILE).mtimeMs;
if (mtime !== localHash.mtime) {
const hex = fs.readFileSync(LOCAL_HASH_FILE, "utf8").trim();
localHash = { mtime, hash: /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null };
}
return localHash.hash;
} catch { localHash = { mtime: -1, hash: null }; return null; }
}
// Пер-пировые серии (метка peer = внутренний IP, name = имя vpn://-пользователя) выключены:
// это и приватность, и кардинальность. Включается явно.
const PER_PEER = (process.env.METRICS_PEERS ?? "off").toLowerCase() === "on";
type Labels = Record<string, string | number>;
type Sample = [Labels, number];
const esc = (v: string | number) => String(v).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
const fmtLabels = (l: Labels) => {
const parts = Object.entries(l).map(([k, v]) => `${k}="${esc(v)}"`);
return parts.length ? `{${parts.join(",")}}` : "";
};
class Exposition {
private lines: string[] = [];
private seen = new Set<string>();
add(name: string, type: "gauge" | "counter", help: string, samples: Sample[]) {
if (!samples.length) return;
if (!this.seen.has(name)) {
this.seen.add(name);
this.lines.push(`# HELP ${name} ${help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n")}`, `# TYPE ${name} ${type}`);
}
for (const [l, v] of samples) this.lines.push(`${name}${fmtLabels(l)} ${Number.isFinite(v) ? v : 0}`);
}
toString() { return this.lines.join("\n") + "\n"; }
}
export function createMetrics(deps: MetricsDeps) {
const { uidb } = deps;
uidb.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
)
`);
const q = {
get: uidb.prepare("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1"),
set: uidb.prepare("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)"),
del: uidb.prepare("DELETE FROM metrics_token WHERE id = 1"),
touch: uidb.prepare("UPDATE metrics_token SET last_used = ? WHERE id = 1"),
};
type TokenRow = { token_hash: Buffer; prefix: string; created_at: number; last_used: number | null };
// ── Управление токеном (роуты панели, за JWT) ──
const router = Router();
const view = () => {
const r = q.get.get() as TokenRow | undefined;
return r ? { exists: true, prefix: r.prefix, created_at: r.created_at, last_used: r.last_used, per_peer: PER_PEER }
: { exists: false, per_peer: PER_PEER };
};
router.get("/", (_req: Request, res: Response) => { res.json(view()); });
// Выпуск и перевыпуск — одно и то же: старый токен перестаёт работать. Значение отдаётся один раз.
router.post("/", (_req: Request, res: Response) => {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
q.set.run(hashToken(token), token.slice(0, 13) + "…", Math.floor(Date.now() / 1000));
res.status(201).json({ ...view(), token });
});
router.delete("/", (_req: Request, res: Response) => { q.del.run(); res.json(view()); });
// ── Доступ к /metrics ──
// Нет токена или он неверный — одинаковый 401: не подсказываем, выключены метрики или токен не тот.
function requireToken(req: Request, res: Response, next: NextFunction) {
const h = req.headers.authorization ?? "";
const given = h.startsWith("Bearer ") ? h.slice(7) : "";
const row = q.get.get() as TokenRow | undefined;
const gh = given.startsWith("awgm_") ? hashToken(given) : null;
const panelOk = !!row && !!gh && crypto.timingSafeEqual(gh, row.token_hash);
const local = localTokenHash();
const localOk = !!gh && !!local && crypto.timingSafeEqual(gh, local);
if (!panelOk && !localOk) { res.status(401).set("WWW-Authenticate", "Bearer").type("text/plain").send("unauthorized\n"); return; }
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
const now = Math.floor(Date.now() / 1000);
if (panelOk && row && (!row.last_used || now - row.last_used >= TOUCH_EVERY_S)) q.touch.run(now);
next();
}
// Один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
async function scrapeServer(s: ServerInfo): Promise<any | null> {
if (s.pending || !deps.servers.isOnline(s.id)) return null;
let timer: NodeJS.Timeout | undefined;
try {
const r = await Promise.race([
deps.servers.ctrlFor(s.id)("GET", "/api/metrics"),
new Promise<never>((_, rej) => { timer = setTimeout(() => rej(new Error("deadline")), SCRAPE_DEADLINE_MS); }),
]);
return r.status === 200 ? r.data : null;
} catch { return null; }
finally { clearTimeout(timer); }
}
async function render(): Promise<string> {
const t0 = process.hrtime.bigint();
const list = deps.servers.list();
const data = await Promise.all(list.map(scrapeServer));
const out = new Exposition();
const L = (s: ServerInfo, extra: Labels = {}): Labels => ({ server: s.name, server_id: s.id, ...extra });
// Серии только тех серверов, чьи метрики получены; fn всегда отдаёт массив серий.
const per = (fn: (s: ServerInfo, d: any) => Sample[]): Sample[] =>
list.flatMap((s, i) => (data[i] ? fn(s, data[i]) : []));
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", [[{ version: deps.version }, 1]]);
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", [[{}, list.length]]);
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", list.map(s => [L(s), s.online ? 1 : 0] as Sample));
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", list.map((s, i) => [L(s), data[i] ? 1 : 0] as Sample));
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.",
list.filter(s => !s.pending).map(s => [L(s, { version: s.version ?? "", gen: s.health?.gen ?? "", module: s.health?.module ?? "", tools: s.health?.tools ?? "" }), 1] as Sample));
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per((s, d) => [[L(s), d.up ? 1 : 0] as Sample]));
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per((s, d) => [[L(s), d.peers?.total ?? 0] as Sample]));
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per((s, d) => [[L(s), d.peers?.online ?? 0] as Sample]));
// Сумма по пирам — gauge, а не counter: при удалении пира или смене его ключа сумма уменьшается,
// и rate() принял бы это за сброс счётчика с ложным всплеском. Для скорости берите awg_link_*_bytes_total.
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.rx_bytes ?? 0] as Sample]));
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.tx_bytes ?? 0] as Sample]));
for (const [k, name, help] of [
["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", "ошибок"],
] as const) {
out.add(`awg_link_${name}_total`, "counter", `Интерфейс awg1: ${help} (${k}).`, per((s, d) => [[L(s), d.link?.[k] ?? 0] as Sample]));
}
// Нагрузка хоста (ядро и ноды). CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
per((s, d) => Object.entries(d.host?.cpu ?? {}).map(([mode, v]) => [L(s, { mode }), Number(v)] as Sample)));
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per((s, d) => d.host ? [[L(s), d.host.cores ?? 0] as Sample] : []));
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.total ?? 0] as Sample] : []));
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.available ?? 0] as Sample] : []));
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).",
per((s, d) => d.host ? (["1m", "5m", "15m"] as const).map((p, i) => [L(s, { period: p }), d.host.load?.[i] ?? 0] as Sample) : []));
// Деление канала (CAKE)
const sh = (fn: (d: any) => number) => per((s, d) => d.shaping ? [[L(s), fn(d.shaping)] as Sample] : []);
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(x => x.settings?.enabled ? 1 : 0));
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(x => x.active ? 1 : 0));
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(x => x.congested ? 1 : 0));
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(x => x.heavy?.length ?? 0));
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(x => x.devices ?? 0));
const dirs = (fn: (x: any, dir: "down" | "up") => number | undefined) =>
per((s, d) => d.shaping ? (["down", "up"] as const).flatMap(dir => {
const v = fn(d.shaping, dir);
return v === undefined ? [] : [[L(s, { direction: dir }), v] as Sample];
}) : []);
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs((x, dir) => (dir === "down" ? x.settings?.downMbit : x.settings?.upMbit) * 1e6));
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs((x, dir) => (dir === "down" ? x.rateDown : x.rateUp) * 1e6));
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs((x, dir) => x[dir]?.drops));
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs((x, dir) => x[dir]?.backlog));
if (PER_PEER) {
const peers = (fn: (p: any) => number) => per((s, d) => (d.list ?? []).map((p: any) =>
[L(s, { peer: p.ip, name: p.name ?? "", kind: p.kind }), fn(p)] as Sample));
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(p => p.online ? 1 : 0));
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(p => p.priority === "background" ? 1 : 0));
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(p => p.rx));
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(p => p.tx));
}
let masters = 0, devices = 0;
// Таблицы создаёт sub: готовим запросы здесь, а не при создании модуля, — порядок
// инициализации в server.ts не должен решать, упадёт ли панель на старте.
try {
masters = (uidb.prepare("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").get() as { n: number }).n;
devices = (uidb.prepare("SELECT COUNT(*) AS n FROM devices").get() as { n: number }).n;
} catch { /* sub ещё не создал таблицы */ }
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", [[{}, masters]]);
out.add("awg_devices", "gauge", "Устройств sen://.", [[{}, devices]]);
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", [[{}, Number(process.hrtime.bigint() - t0) / 1e9]]);
return out.toString();
}
async function handler(_req: Request, res: Response) {
try {
res.type("text/plain; version=0.0.4; charset=utf-8").send(await render());
} catch (e) {
console.warn(`metrics: ${(e as Error).message}`);
res.status(500).type("text/plain").send("error\n");
}
}
return { router, requireToken, handler };
}
+220 -18
View File
@@ -15,6 +15,7 @@ const now = () => Math.floor(Date.now() / 1000);
const rnd = (n: number) => Math.floor(Math.random() * n);
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
const GEN = "3.1";
const MOCK_VERSION = "0.3.3";
// ── пользователи (vpn://) ──
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
@@ -34,12 +35,13 @@ const usersOf: Record<number, U[]> = {
};
const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; };
const U_ = (q: express.Request) => usersOf[srv(q)];
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean }
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null }
const mnodes: MNode[] = [
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false },
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true },
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.3.2" }, // отстаёт от панели
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null },
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: true, pending: false, version: null }, // старый агент: версию не шлёт
];
let nodeId = 2;
let nodeId = 3;
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
// ── API-ключи ──
@@ -48,17 +50,23 @@ let apiId = 1;
// ── мастер-ключи sen:// ──
interface Dev { 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 }
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
let mkId = 1, devId = 1;
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] },
{ id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0] },
{ id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }];
const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, 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 } : {}) });
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, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
const devTorrent = (d: Dev) => !!torrentOf[0]?.enabled && d.id === masters[0]?.devs[1]?.id;
const devOut = (m: MK, d: Dev) => ({ ...d,
priority: shapingOf[0]?.enabled && (d.id === masters[0]?.devs[0]?.id || devTorrent(d)) ? "background" : "normal",
torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length,
ips: m.servers.filter(s => s !== 2).map(s => ({ server_id: s, ip: `10.9.0.${50 + d.id + s * 10}` })) });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (q, r) => {
@@ -68,15 +76,15 @@ app.get("/health", (q, r) => {
// ── серверы ──
const nodeView = () => [
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null,
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, version: MOCK_VERSION, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, version: n.version,
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
];
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() }));
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
app.post("/ui/nodes", (q, r) => {
const name = String(q.body.name ?? "");
if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" });
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true });
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true, version: null });
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 });
});
app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); });
@@ -93,8 +101,164 @@ app.delete("/ui/nodes/:id", (q, r) => {
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
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, хранение 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;
let monReady = false;
const MON_MS = 6000;
const monRunning = () => !!monJob && Date.now() - monJob.start < MON_MS;
app.get("/ui/monitor", (_q, r) => {
if (monJob && !monRunning()) monReady = monJob.action === "install";
let job = null;
if (monJob) {
const lines = MON_LOGS[monJob.action];
const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length;
job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") };
}
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", job });
});
for (const action of ["install", "remove"] as const) {
app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); });
}
// Перенос панели (internal/ui/migrate.go). Мок — standalone: переносить можно, принимать нельзя;
// MOCK_ROLE=core — наоборот (код «принимается» через 10 с, ещё через 10 с — «перезапуск»);
// MOCK_ROLE=retire — панель уже приняла перенос: готовность к отключению старого сервера.
const MOCK_CORE = process.env.MOCK_ROLE === "core" || process.env.MOCK_ROLE === "retire";
let mv: { state: string; error: string | null; target: string | null; code: { code: string; at: number } | null; movedAt: number } =
{ state: "", error: null, target: null, code: null, movedAt: process.env.MOCK_ROLE === "retire" ? Date.now() : 0 };
const mvRetire = () => {
if (!mv.movedAt) return null;
const t = (Date.now() - mv.movedAt) / 1000; // клиенты и ноды «узнают» адрес со временем
return { since: Math.floor(mv.movedAt / 1000), home: "198.51.100.20:40500",
old: { exists: true, online: true },
devices: { total: 12, fresh: Math.min(12, 7 + Math.floor(t / 15)) }, unused_links: 1,
nodes: [{ id: 1, name: "Frankfurt", online: true, ok: true }, { id: 2, name: "Tokyo", online: false, ok: false },
{ id: 4, name: "Amsterdam", online: true, ok: t > 20 }] };
};
const mvView = () => {
if (mv.code && Date.now() - mv.code.at > 10000) mv = { ...mv, state: "imported", code: null, movedAt: Date.now() };
if (mv.state === "imported" && Date.now() - mv.movedAt > 10000) mv = { ...mv, state: "" };
return { state: mv.state, error: mv.error, target: mv.target,
code: mv.code && { code: mv.code.code, expires_in: 3600 - Math.floor((Date.now() - mv.code.at) / 1000) },
...(mv.state === "imported" ? {} : {
export: MOCK_CORE ? "У этой панели нет своего VPN: переносить её как сервер-ноду нечего" : null,
accept: !MOCK_CORE ? "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
: mv.movedAt ? "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую" : null,
retire: mvRetire() }) };
};
app.get("/ui/migrate", (_q, r) => r.json(mvView()));
app.post("/ui/migrate/code", (q, r) => {
if (!MOCK_CORE) return r.status(409).json({ error: "У этой панели свой VPN" });
mv.code = { code: "awgmove://" + Buffer.from(JSON.stringify({ v: 1, h: q.body?.host ?? "127.0.0.1", p: 8080, k: "mock" })).toString("base64url"), at: Date.now() };
r.json(mvView());
});
app.delete("/ui/migrate/code", (_q, r) => { mv.code = null; r.json(mvView()); });
app.post("/ui/migrate/export", (q, r) => {
const code = String(q.body?.code ?? "");
setTimeout(() => {
if (!code.startsWith("awgmove://ey")) return r.status(400).json({ error: "код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели" });
mv = { ...mv, state: "moved", target: "http://198.51.100.20:31337" };
r.json({ ok: true, target: mv.target });
}, 1500);
});
app.post("/ui/migrate/cancel", (_q, r) => { mv = { ...mv, state: "", error: null }; r.json(mvView()); });
app.delete("/ui/migrate/retire", (_q, r) => { mv.movedAt = 0; r.json(mvView()); });
app.get("/ui/monitor/overview", (q, r) => {
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "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))]);
const wave = (t: number, per: number, ph = 0) => Math.sin((t / per) * 2 * Math.PI + ph);
const per = sec / 3;
const down = pts((_, t) => 110e6 + 70e6 * wave(t, per) + 25e6 * wave(t, per / 7) + rnd(12e6));
const up = pts((_, t) => 14e6 + 7e6 * wave(t, per, 1) + rnd(3e6));
const online = pts((_, t) => Math.round(9 + 3 * wave(t, per * 1.6) + (rnd(100) > 90 ? 1 : 0)));
const heavy = pts((_, t) => (wave(t, per) > 0.55 ? 1 : 0));
const drops = pts((_, t) => (wave(t, per) > 0.55 ? 3 + rnd(6) : rnd(10) > 8 ? 0.4 : 0));
const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6));
const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2));
const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]);
// Один сервер — тот, что выбран в сервер-баре (?server=<id>): ядро (0) или нода
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,
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) } });
});
// Prometheus: токен метрик и сам /metrics (без проверки токена — мок).
let promTok: { prefix: string; created_at: number; last_used: number | null } | null = null;
const promView = () => promTok ? { exists: true, ...promTok, per_peer: false } : { exists: false, per_peer: false };
app.get("/ui/metrics-token", (_q, r) => r.json(promView()));
app.post("/ui/metrics-token", (_q, r) => {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
promTok = { prefix: token.slice(0, 13) + "…", created_at: now(), last_used: null };
r.status(201).json({ ...promView(), token });
});
app.delete("/ui/metrics-token", (_q, r) => { promTok = null; r.json(promView()); });
app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n'));
// Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»).
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = {};
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", ...(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,
};
};
app.get("/api/shaping", (q, r) => srv(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(shapingView(srv(q))));
app.put("/api/shaping", (q, r) => {
const id = srv(q), b = q.body ?? {};
if (id === 1) return r.status(404).json({ error: "Not found" });
if (b.enabled && !(b.downMbit >= 1 && b.upMbit >= 1)) return r.status(400).json({ error: "Для включения укажите ширину канала в обе стороны" });
shapingOf[id] = { enabled: !!b.enabled, downMbit: Number(b.downMbit) || 0, upMbit: Number(b.upMbit) || 0, auto: !!b.auto };
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) }));
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) }));
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
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);
@@ -117,17 +281,48 @@ app.patch("/ui/apikeys/:id", (q, r) => {
});
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
let mockAuditSettings = { clean: false, every_h: 1, max: 10 };
let mockCleaned: unknown[] = [];
let mockGhost = true;
const mockAudit = () => ({ enabled: true, settings: mockAuditSettings, cleaned: mockCleaned, report: { at: Math.floor(Date.now() / 1000) - 600, servers: [
{ server_id: 0, status: "ok", ghosts_checked: true, foreign: 0, orphans: [], ghosts: mockGhost ? [
{ pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", online: true, lastHandshake: Math.floor(Date.now() / 1000) - 30, seen_before: true } ] : [] } ] } });
app.get("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
app.post("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
app.post("/ui/masterkeys/audit/clean", (_q, r) => {
if (mockGhost) mockCleaned = [{ at: Math.floor(Date.now() / 1000), server_id: 0, pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", kind: "ghost", auto: false }, ...mockCleaned];
mockGhost = false; r.json(mockAudit());
});
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
let mkDefaults: number[] | null = null;
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut), defaults: mkDefaults }));
// «Серверы для всех» (sub_admin.go bulkServers): add — у всех, remove — ни у кого.
app.post("/ui/masterkeys/servers", (q, r) => {
const add: number[] = q.body.add ?? [], remove: number[] = q.body.remove ?? [];
if (add.some(x => remove.includes(x))) return r.status(400).json({ error: "Сервер не может быть и у всех, и ни у кого" });
const next = masters.map(m => ({ m, s: [...new Set([...m.servers.filter(x => !remove.includes(x)), ...add])].sort() }));
const empty = next.filter(x => !x.s.length).length;
if (empty) return r.status(409).json({ error: `У ${empty} ключей не останется ни одного сервера — оставьте им хотя бы один` });
const changed = next.filter(x => x.s.join() !== [...x.m.servers].sort().join());
const within = (l: number[], sc: number[]) => l.every(x => sc.includes(x));
const hidden = apiKeys.map(k => ({ id: k.id, label: k.label, keys: changed.filter(x => within(x.m.servers, k.servers) && !within(x.s, k.servers)).length })).filter(h => h.keys);
if (!q.body.dry_run) { changed.forEach(x => { x.m.servers = x.s; }); if ("defaults" in q.body) mkDefaults = q.body.defaults; }
r.json({ changed: changed.length, total: masters.length, hidden });
});
app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3;
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? mkDefaults ?? [0] };
masters.push(m); r.status(201).json(mkOut(m));
});
app.patch("/ui/masterkeys/:id", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); }
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m));
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit;
// Как sub.ts: лишние устройства сверх лимита — отвязать, начиная с подключившихся последними.
const unbound = [...m.devs].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, Math.max(0, m.devs.length - m.device_limit)).map(d => d.id);
m.devs = m.devs.filter(d => !unbound.includes(d.id));
r.json({ ...mkOut(m), unbound });
});
app.post("/ui/masterkeys/:id/rotate", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.secret = crypto.randomBytes(16); r.json({ id: m.id }); });
app.post("/ui/masterkeys/:id/rekey", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.devs.forEach(d => d.rekey_requested = true); r.json({ id: m.id }); });
@@ -141,7 +336,14 @@ app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
});
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : r.status(404).json({ error: "Не найден" }); });
// MOCK_MK_FAIL=<id ключа> — /devices этого ключа отвечает 502; MOCK_MK_DOWN=<id сервера> — этот сервер «не ответил».
app.get("/ui/masterkeys/:id/devices", (q, r) => {
const m = find(q.params.id);
if (!m) return r.status(404).json({ error: "Не найден" });
if (process.env.MOCK_MK_FAIL === String(m.id)) return r.status(502).json({ error: "awg-ctrl недоступен" });
const down = Number(process.env.MOCK_MK_DOWN);
r.json({ devices: m.devs.map(d => devOut(m, d)), unreachable: m.servers.filter(s => s === down) });
});
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
+195
View File
@@ -0,0 +1,195 @@
// 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.
//
// Вкладка «Монитор»: встроенный 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 ч
// Панель сама пакетов не ставит и в Prometheus лезет только фиксированными запросами (не PromQL от клиента).
import axios from "axios";
import { Router, type Request, type Response } from "express";
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
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 },
"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 → ряд
// Запросы по серверам. Трафик берём со счётчиков интерфейса (монотонны), а не с сумм по пирам:
// сумма по пирам падает при удалении пира, и rate() рисовал бы ложный всплеск.
const QUERIES = {
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)',
// Загрузка процессора: доля времени не в idle/iowait. Память: MemAvailable, а не MemFree (кэш ядра свободен).
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)',
} 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,
});
const out: Series = new Map();
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; values: [number, string][] }[]) {
const id = Number(m.metric.server_id);
if (!Number.isInteger(id)) continue;
out.set(id, {
name: m.metric.server ?? String(id),
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
points: m.values.map(([t, v]) => [t, Number(v)] as Point).filter(p => Number.isFinite(p[1])),
});
}
return out;
}
// Сумма рядов по меткам времени (Prometheus выравнивает точки по шагу, метки совпадают).
function total(series: Series, how: "sum" | "max" = "sum"): Point[] {
const acc = new Map<number, number>();
for (const s of series.values()) for (const [t, v] of s.points)
acc.set(t, how === "sum" ? (acc.get(t) ?? 0) + v : Math.max(acc.get(t) ?? 0, v));
return [...acc.entries()].sort((a, b) => a[0] - b[0]);
}
const last = (s?: { points: Point[] }): number | null => s && s.points.length ? s.points[s.points.length - 1][1] : null;
export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean }) {
const router = Router();
const promReady = async (): Promise<boolean> => {
try { return (await axios.get(`${PROM_URL}/-/ready`, { timeout: 1500, validateStatus: () => true })).status === 200; }
catch { return false; }
};
// Установка возможна, только если рядом свой awg-ctrl (у панели без собственного VPN его нет —
// тогда остаётся команда в терминале) и он достаточно новый, чтобы знать маршрут.
type Job = { can: boolean; action: "install" | "remove" | null; running: boolean; exit: number | null; log: string };
async function jobState(): Promise<Job> {
const none: Job = { can: false, action: null, running: false, exit: null, log: "" };
if (!deps.localEnabled) return none;
try {
const r = await deps.ctrlLocal("GET", "/api/monitor/job");
if (r.status !== 200) return none;
return { can: true, action: r.data.action ?? null, running: !!r.data.running, exit: r.data.exit ?? null, log: String(r.data.log ?? "") };
} catch { return none; }
}
router.get("/", async (_req: Request, res: Response) => {
const [ready, job] = await Promise.all([promReady(), jobState()]);
res.json({
ready,
can_install: job.can,
// Последняя операция (установка или удаление) и её лог; null — ещё не запускали
job: job.can && job.action ? { action: job.action, running: job.running, exit: job.exit, log: job.log } : null,
command: "awg-ctrl monitor install",
});
});
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
const ask = (action: "install" | "remove") => async (_req: Request, res: Response) => {
if (!deps.localEnabled) { res.status(409).json({ error: "У этой панели нет своего VPN-сервера: выполните команду на машине" }); return; }
try {
const r = await deps.ctrlLocal("POST", `/api/monitor/${action}`);
res.status(r.status === 404 ? 501 : r.status).json(
r.status === 404 ? { error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" } : r.data);
} catch { res.status(502).json({ error: "awg-ctrl недоступен" }); }
};
router.post("/install", ask("install"));
router.post("/remove", ask("remove"));
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, 7d или 30d" }); return; }
const end = Math.floor(Date.now() / 1000);
const start = end - range.seconds;
try {
const keys = Object.keys(QUERIES) as Key[];
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,
only === null ? got[i] : new Map([...got[i]].filter(([id]) => id === only))])) as Record<Key, Series>;
// Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна.
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: 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: 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,
}));
res.json({
range: req.query.range ?? "1h", step: range.step, from: start, to: end,
servers,
total: {
down: total(s.down), up: total(s.up), online: total(s.online),
heavy: total(s.heavy), drops: total(s.drops),
cpu: total(s.cpu, "max"), mem: total(s.mem, "max"),
},
});
} catch (e) {
// Prometheus стоит, но не ответил: график важнее ошибки, но причину оператору покажем
res.status(502).json({ error: axios.isAxiosError(e) ? "Prometheus не отвечает" : (e as Error).message });
}
});
return { router };
}
+23 -11
View File
@@ -25,7 +25,7 @@ export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<
interface NodeRow {
id: number; name: string; pub: string | null;
join_hash: Buffer | null; join_expires: number | null;
created_at: number; last_seen: number | null;
created_at: number; last_seen: number | null; version: string | null;
}
interface Health {
@@ -46,11 +46,12 @@ const RPC_TIMEOUT = 15_000;
const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго
const NAME_RE = /^[^\r\n<>]{1,40}$/;
const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/;
const VERSION_RE = /^[\w.+-]{1,32}$/;
// Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent.
const authMsg = (nonce: string, nodeId: number) => Buffer.from(`awg-node-v1\n${nonce}\n${nodeId}`);
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string }) {
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string; version: string }) {
const { uidb, ctrlLocal } = deps;
const NODE_PORT = Number(process.env.NODE_PORT) || 0;
@@ -94,6 +95,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
last_seen INTEGER
);
`);
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают). Колонка
// добавлена позже, у существующей таблицы её может не быть.
if (!(uidb.prepare("PRAGMA table_info(nodes)").all() as { name: string }[]).some(c => c.name === "version"))
uidb.exec("ALTER TABLE nodes ADD COLUMN version TEXT");
const q = {
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
@@ -101,7 +106,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"),
rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"),
bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"),
seen: uidb.prepare<[number, number]>("UPDATE nodes SET last_seen = ? WHERE id = ?"),
seen: uidb.prepare<[number, string | null, number]>("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?"),
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
};
const now = () => Math.floor(Date.now() / 1000);
@@ -225,7 +230,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
clearTimeout(authTimer);
if (joining) q.bind.run(pubB64 as string, row.id);
q.seen.run(now(), row.id);
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
const version = typeof m.version === "string" && VERSION_RE.test(m.version) ? m.version : null;
q.seen.run(now(), version, row.id);
const old = conns.get(row.id);
conn = { ws, seq: 0, alive: true, pending: new Map() };
@@ -298,20 +305,25 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const router = Router();
router.get("/", async (_req: Request, res: Response) => {
if (LOCAL) await refreshHealth(0);
const nodes: unknown[] = [];
// Серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и для метрик.
const list = () => {
const nodes: { id: number; name: string; local: boolean; online: boolean; pending: boolean; last_seen: number | null; version: string | null; health: Health | null }[] = [];
if (LOCAL) {
const h = health.get(0) ?? null;
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, health: h });
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
}
for (const n of q.all.all() as NodeRow[]) {
nodes.push({
id: n.id, name: n.name, local: false, online: conns.has(n.id),
pending: n.pub === null, last_seen: n.last_seen, health: health.get(n.id) ?? null,
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
});
}
res.json({ hub: hubReady(), local: LOCAL, nodes });
return nodes;
};
router.get("/", async (_req: Request, res: Response) => {
if (LOCAL) await refreshHealth(0);
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes: list() });
});
router.post("/", (req: Request, res: Response) => {
@@ -372,5 +384,5 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
res.json({ success: true, id });
});
return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, afterDelete, defaultId, start };
return { router, ctrlFor, exists, isOnline, ids, list, onOnline, beforeDelete, afterDelete, defaultId, start };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.3.3",
"version": "0.4.5",
"private": true,
"scripts": {
"dev": "vite",
+12 -1
View File
@@ -9,6 +9,8 @@ import axios from "axios";
import Database from "better-sqlite3";
import { createSub } from "./sub";
import { createNodes } from "./nodes";
import { createMetrics } from "./metrics";
import { createMonitor } from "./monitor";
const app = express();
const PORT = Number(process.env.PORT) || 8080;
@@ -355,13 +357,22 @@ app.use("/api/v1", ext);
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname, version: VERSION });
app.use("/ui/nodes", requireAuth, nodes.router);
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices);
// Prometheus: /metrics за отдельным токеном «только метрики» (не JWT и не ключ API), токеном
// управляет панель. Регистрируется до SPA-фолбэка внизу, иначе его перехватит `*`.
const metrics = createMetrics({ uidb, version: VERSION, servers: nodes });
app.use("/ui/metrics-token", requireAuth, metrics.router);
app.get("/metrics", metrics.requireToken, metrics.handler);
// Монитор: встроенный Prometheus и графики во вкладке. Ставит его не панель, а awg-ctrl от root.
const monitor = createMonitor({ ctrlLocal: ctrl, localEnabled: (process.env.LOCAL_NODE ?? "on").toLowerCase() !== "off" });
app.use("/ui/monitor", requireAuth, monitor.router);
// Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован
// на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже.
ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? []));
+44
View File
@@ -25,6 +25,8 @@ html, body, #root { height: 100%; }
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
--warn-dot: #e08a2c;
--tertiary-container: #ffdcad; /* design.md: tertiary-container */
--on-tertiary-container: #7a4a14;
--danger-border: #e6c4bc;
--danger-text: #b34a3a;
@@ -67,6 +69,8 @@ html, body, #root { height: 100%; }
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
--warn-dot: #ffb76b;
--tertiary-container: #4a3218; /* тёплый тёмный, как у статусов в тёмной теме */
--on-tertiary-container: #ffdcad;
--danger-border: #6e4540;
--danger-text: #ffb4ab;
@@ -328,6 +332,8 @@ body {
cursor: default;
user-select: none;
}
.nav-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
.nav-group-title { padding: 8px 12px 4px; font-size: 12px; color: var(--neutral-text); user-select: none; }
.nav-item--active {
background: var(--primary-container);
color: var(--on-primary-container);
@@ -411,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);
@@ -429,6 +436,22 @@ body {
user-select: all;
}
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
/* Настройки сервера: деление канала */
.ss-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; }
.ss-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ss-setting-name { font-size: 14px; color: var(--on-surface); }
.ss-setting input[type="checkbox"] { width: 20px; height: 20px; flex: none; accent-color: var(--primary); }
.ss-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.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); }
.server-card-info {
display: flex;
flex-direction: column;
@@ -449,6 +472,16 @@ body {
line-height: 1.2;
}
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
.server-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.server-chips .server-gen-wrap, .server-chips .server-gen-chip { margin-top: 0; }
.server-ver-chip {
font-family: var(--mono);
font-size: 11px;
padding: 2px 8px;
background: var(--neutral-bg);
color: var(--neutral-text);
}
.server-ver-chip--warn { background: var(--tertiary-container); color: var(--on-tertiary-container); }
.server-gen-chip {
align-self: flex-start;
margin-top: 2px;
@@ -752,3 +785,14 @@ tbody td:last-child { padding-right: 20px; }
.server-pick-name { font-weight: 500; }
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
.server-pick-ip { font-family: var(--mono); margin-left: auto; }
/* Короткий идентификатор под именем (UUID мастер-ключа, ID устройства):
первые символы моноширинным, полностью — в подсказке, по клику копируется. */
.name-stack { display: inline-flex; flex-direction: column; line-height: 1.25; vertical-align: middle; }
.copy-id {
align-self: flex-start;
background: none; border: 0; padding: 0; margin: 0; cursor: pointer;
font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--neutral-text);
}
.copy-id:hover { color: var(--primary); }
.copy-id:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
+48 -9
View File
@@ -11,7 +11,8 @@ import {
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import ServerBar from './components/ServerBar';
import AddServerModal from './components/AddServerModal';
import { TABS } from './tabs';
import ServerSettingsModal from './components/ServerSettingsModal';
import { TABS, TABS_BY_GROUP } from './tabs';
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
@@ -23,8 +24,10 @@ export default function App() {
const [statusText, setStatusText] = useState('');
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [hubEnabled, setHubEnabled] = useState(false);
const [panelVersion, setPanelVersion] = useState<string | null>(null);
const [serverId, setServerIdState] = useState<number | null>(null);
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
const [settingsFor, setSettingsFor] = useState<NodeInfo | null>(null);
const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id);
@@ -55,6 +58,7 @@ export default function App() {
const list: NodeInfo[] = data.nodes ?? [];
setNodes(list);
setHubEnabled(!!data.hub);
setPanelVersion(data.version ?? null);
const cur = getServerId();
if (cur !== null && list.some(n => n.id === cur)) return;
let saved: number | null = null;
@@ -145,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);
@@ -286,14 +308,19 @@ export default function App() {
</span>
</div>
<hr className="drawer-divider" />
{TABS.map(t => (
<div
key={t.id}
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
{TABS_BY_GROUP.map(g => (
<nav key={g.id} className="nav-group" aria-label={g.label}>
<span className="nav-group-title">{g.label}</span>
{g.tabs.map(t => (
<div
key={t.id}
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
))}
</nav>
))}
<div className="sidebar-spacer" />
@@ -311,10 +338,13 @@ export default function App() {
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
panelVersion={panelVersion}
onSelect={pickServer}
onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })}
onDelete={deleteNode}
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
onSettings={n => { pickServer(n.id); setSettingsFor(n); }}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
@@ -343,6 +373,15 @@ export default function App() {
/>
)}
{settingsFor && (
<ServerSettingsModal
token={token}
node={settingsFor}
onClose={() => setSettingsFor(null)}
showMsg={showMsg}
/>
)}
{msg && <div className="snack">{msg}</div>}
</div>
);
+194
View File
@@ -0,0 +1,194 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useEffect, useMemo, useRef, useState } from 'react';
// Линейный график для вкладки «Монитор». Одна ось, линия 2 px, конечная точка с кольцом цвета поверхности,
// перекрестие с подсказкой по всем рядам, легенда при ≥ 2 рядах, табличный вид и управление стрелками.
// Цвета рядов — токены темы (--series-N в monitor.css), подписи и значения — всегда цветом текста.
export interface ChartSeries { id: string; name: string; color: string; points: [number, number][] }
const M = { left: 46, right: 14, top: 10, bottom: 24 };
// Шкала оси: круглый шаг (1, 2, 2.5, 5 × 10ⁿ) и 3–5 интервалов, чтобы подписи были 0 / 50 / 100 / 150, а не 0 / 63 / 125.
function niceScale(max: number): { top: number; ticks: number[] } {
if (!(max > 0)) return { top: 1, ticks: [0, 1] };
const raw = max / 4;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
const step = ([1, 2, 2.5, 5, 10].map(f => f * p).find(s => s >= raw)) ?? 10 * p;
const n = Math.max(1, Math.ceil(max / step - 1e-9));
return { top: step * n, ticks: Array.from({ length: n + 1 }, (_, i) => step * i) };
}
const timeLabel = (t: number, span: number) => {
const d = new Date(t * 1000);
const hm = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
return span > 86400 * 1.5 ? `${d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} ${hm}` : hm;
};
export default function LineChart({ title, series, from, to, step, format, formatAxis, max, dim = false }: {
title: string;
series: ChartSeries[];
from: number;
to: number;
step: number; // шаг данных, с: разрыв длиннее трёх шагов рвёт линию, а не рисует мост
format: (v: number) => string; // значение в подсказке и таблице
formatAxis?: (v: number) => string; // компактная подпись оси
max?: number; // фиксированный верх оси (проценты — 100); без него подбирается по данным
dim?: boolean; // идёт перезагрузка: держим прежний кадр бледнее
}) {
const wrap = useRef<HTMLDivElement>(null); // содержимое карточки без её padding: от него зависит ширина svg
const [w, setW] = useState(640);
const [hover, setHover] = useState<number | null>(null); // индекс по оси времени
const [table, setTable] = useState(false);
const H = 200;
useEffect(() => {
const el = wrap.current;
if (!el) return;
const ro = new ResizeObserver(() => setW(Math.max(260, el.clientWidth)));
ro.observe(el); setW(Math.max(260, el.clientWidth));
return () => ro.disconnect();
}, []);
// Общая ось времени: метки первого непустого ряда (Prometheus выравнивает точки по шагу).
const times = useMemo(() => {
const set = new Set<number>();
for (const s of series) for (const p of s.points) set.add(p[0]);
return [...set].sort((a, b) => a - b);
}, [series]);
const byTime = useMemo(() => series.map(s => new Map(s.points)), [series]);
const empty = times.length === 0;
const { top: yMax, ticks: yTicks } = max !== undefined
? { top: max, ticks: [0, 1, 2, 3, 4].map(i => (max / 4) * i) }
: niceScale(Math.max(0, ...series.flatMap(s => s.points.map(p => p[1]))) * 1.04);
const x = (t: number) => M.left + ((t - from) / Math.max(1, to - from)) * (w - M.left - M.right);
const y = (v: number) => H - M.bottom - (v / yMax) * (H - M.top - M.bottom);
const path = (pts: [number, number][]) => {
let d = '', prev: number | null = null;
for (const [t, v] of pts) {
d += `${prev !== null && t - prev <= step * 3 ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`;
prev = t;
}
return d;
};
const span = to - from;
const xTicks = [0, 1, 2, 3, 4].map(i => from + (span / 4) * i);
const axis = formatAxis ?? format;
const onMove = (e: React.PointerEvent<SVGRectElement>) => {
if (!times.length) return;
const r = e.currentTarget.getBoundingClientRect();
const t = from + ((e.clientX - r.left) / Math.max(1, r.width)) * span;
let best = 0;
for (let i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
setHover(best);
};
const onKey = (e: React.KeyboardEvent) => {
if (!times.length) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); setHover(h => Math.max(0, (h ?? times.length) - 1)); }
if (e.key === 'ArrowRight') { e.preventDefault(); setHover(h => Math.min(times.length - 1, (h ?? -1) + 1)); }
if (e.key === 'Escape') setHover(null);
};
const ht = hover !== null ? times[hover] : null;
// Подсказка не вылезает за правый край: правее середины открывается влево от перекрестия
const flip = ht !== null && x(ht) > w * 0.55;
// Табличный вид — те же числа без наведения: ~12 равномерно выбранных строк, последняя — самая свежая
const rows = useMemo(() => {
if (!times.length) return [] as number[];
const n = Math.min(12, times.length);
return Array.from({ length: n }, (_, i) => times[Math.round(((times.length - 1) * (i + 1)) / n)]).reverse();
}, [times]);
return (
<figure className={`mon-chart${dim ? ' mon-chart--dim' : ''}`}>
<figcaption className="mon-chart-head">
<span className="mon-chart-title">{title}</span>
{series.length >= 2 && (
<span className="mon-legend">
{series.map(s => (
<span className="mon-legend-item" key={s.id}>
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />{s.name}
</span>
))}
</span>
)}
<button type="button" className="mon-chart-toggle" aria-pressed={table} onClick={() => setTable(t => !t)}>
{table ? 'График' : 'Таблица'}
</button>
</figcaption>
<div ref={wrap}>
{table ? (
<div className="mon-table-wrap">
<table className="mon-table">
<thead><tr><th>Время</th>{series.map(s => <th key={s.id}>{s.name}</th>)}</tr></thead>
<tbody>
{rows.length === 0 ? <tr><td colSpan={series.length + 1}>Нет данных</td></tr> : rows.map(t => (
<tr key={t}>
<td>{timeLabel(t, span)}</td>
{series.map((s, i) => <td key={s.id}>{byTime[i].has(t) ? format(byTime[i].get(t)!) : '—'}</td>)}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="mon-plot">
<svg width={w} height={H} role="img" tabIndex={0} aria-label={`${title}. Стрелки влево и вправо — значения по времени.`}
onKeyDown={onKey} onBlur={() => setHover(null)}>
{yTicks.map((v, i) => (
<g key={i}>
<line className="mon-grid" x1={M.left} x2={w - M.right} y1={y(v)} y2={y(v)} />
<text className="mon-tick" x={M.left - 8} y={y(v) + 4} textAnchor="end">{axis(v)}</text>
</g>
))}
{xTicks.map((t, i) => (
<text key={i} className="mon-tick" x={x(t)} y={H - 6} textAnchor={i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}>
{timeLabel(t, span)}
</text>
))}
{!empty && series.map(s => (
<path key={s.id} d={path(s.points)} fill="none" style={{ stroke: s.color }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
))}
{/* конечная точка: r 4 + кольцо цвета поверхности, чтобы читалась поверх линий */}
{!empty && series.map(s => s.points.length > 0 && (
<circle key={s.id} cx={x(s.points[s.points.length - 1][0])} cy={y(s.points[s.points.length - 1][1])}
r={4} style={{ fill: s.color }} className="mon-end" />
))}
{ht !== null && (
<g>
<line className="mon-cross" x1={x(ht)} x2={x(ht)} y1={M.top} y2={H - M.bottom} />
{series.map((s, i) => byTime[i].has(ht) && (
<circle key={s.id} cx={x(ht)} cy={y(byTime[i].get(ht)!)} r={4} style={{ fill: s.color }} className="mon-end" />
))}
</g>
)}
{/* зона наведения шире линии: указатель ищет момент времени, а не 2-пиксельную линию */}
<rect x={M.left} y={M.top} width={Math.max(0, w - M.left - M.right)} height={H - M.top - M.bottom}
fill="transparent" onPointerMove={onMove} onPointerLeave={() => setHover(null)} />
</svg>
{empty && <p className="mon-empty">Данные появятся через минуту-две после запуска Prometheus</p>}
{ht !== null && (
<div className="mon-tip" style={{ left: flip ? undefined : x(ht) + 12, right: flip ? w - x(ht) + 12 : undefined, top: M.top }} role="status">
<span className="mon-tip-time">{timeLabel(ht, span)}</span>
{series.map((s, i) => (
<span className="mon-tip-row" key={s.id}>
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />
<b>{byTime[i].has(ht) ? format(byTime[i].get(ht)!) : '—'}</b>
<span>{s.name}</span>
</span>
))}
</div>
)}
</div>
)}
</div>
</figure>
);
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import type { Priority } from '../lib/shared';
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
// деления канала) чип не показываем: «обычный» там было бы выдумкой. С меткой «торренты» устройство
// фоновое независимо от загрузки канала — подсказка говорит об этом.
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
? 'Обычный приоритет: канал делится поровну'
: torrent
? 'Пытается качать торренты — пока висит метка, устройство уступает остальным'
: 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'}
>
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
</span>
</span>
);
}
+45 -8
View File
@@ -1,23 +1,46 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { type NodeInfo, genLabel } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию
// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить».
function versionChip(n: NodeInfo, panel: string | null) {
if (n.pending) return null;
let tip = '', warn = false;
if (!n.version) {
warn = true;
tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)';
} else if (panel && !n.local) {
const d = cmpVersion(n.version, panel);
if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; }
}
const chip = (
<span className={`chip server-ver-chip${warn ? ' server-ver-chip--warn' : ''}`}>
{n.version ? `v${n.version}` : 'v?.?.?'}
</span>
);
return tip ? <span className="tip-wrap server-gen-wrap" data-tip={tip}>{chip}</span> : chip;
}
export default function ServerBar({
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete, onSettings,
onRestartAwg, restarting, showRestart = true,
}: {
nodes: NodeInfo[];
panelVersion: string | null;
serverId: number | null;
hubEnabled: boolean;
onSelect: (id: number) => void;
onAdd: () => void;
onJoin: (n: NodeInfo) => void;
onDelete: (n: NodeInfo) => void;
onSettings: (n: NodeInfo) => void;
onRestartAwg: () => void;
restarting: boolean;
showRestart?: boolean;
@@ -52,9 +75,12 @@ export default function ServerBar({
) : !n.online ? (
<span className="server-card-ip">нет связи</span>
) : h && (
<>
<span className="server-card-ip">{h.ip}</span>
{(h.module || h.tools) ? (
<span className="server-card-ip">{h.ip}</span>
)}
{/* Плашки: поколение AWG (только на связи) и версия Forgetting (известна и без связи) */}
{!n.pending && (
<span className="server-chips">
{h && n.online && ((h.module || h.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
@@ -63,8 +89,9 @@ export default function ServerBar({
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
)}
</>
))}
{versionChip(n, panelVersion)}
</span>
)}
</div>
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
@@ -77,6 +104,16 @@ export default function ServerBar({
<IcoCopy />
</button>
)}
{/* Настройки сервера: у ноды, которая ещё не подключалась, настраивать нечего */}
{!n.pending && n.online && (
<button
className="btn-icon btn-icon--neutral"
aria-label={`Настройки сервера ${n.name}`}
onClick={e => { e.stopPropagation(); onSettings(n); }}
>
<IcoSettings />
</button>
)}
{!n.local && (
<button
className="btn-icon btn-icon--danger"
@@ -0,0 +1,237 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useEffect, useState } from 'react';
import axios from 'axios';
import { apiFetch, type NodeInfo, type ShapingStatus, type TorrentStatus } from '../lib/shared';
// Настройки сервера: деление канала между устройствами (CAKE) и блокировка BitTorrent. Сами
// настройки живут в awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
token: string;
node: NodeInfo;
onClose: () => void;
showMsg: (text: string) => void;
}) {
const [st, setSt] = useState<ShapingStatus | null>(null);
const [enabled, setEnabled] = useState(false);
const [auto, setAuto] = useState(true);
const [down, setDown] = useState('');
const [up, setUp] = useState('');
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);
setEnabled(s.settings.enabled); setAuto(s.settings.auto);
setDown(s.settings.downMbit ? String(s.settings.downMbit) : '');
setUp(s.settings.upMbit ? String(s.settings.upMbit) : '');
};
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)
.then(s => { if (alive) fill(s as ShapingStatus); })
.catch(e => {
if (!alive) return;
// Старый awg-ctrl не знает этот маршрут — его надо обновить, а не «починить» здесь.
if (axios.isAxiosError(e) && e.response?.status === 404) setUnsupported(true);
else setError(errText(e, 'Не удалось загрузить настройки'));
});
return () => { alive = false; };
}, [token]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
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 {
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); }
};
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>
<h4 className="ss-section">Деление канала</h4>
{unsupported ? (
<p className="node-modal-hint">
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
</p>
) : (
<>
<p className="node-modal-hint">
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
</p>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Честное деление между устройствами</span>
</span>
<input type="checkbox" checked={enabled} disabled={!st || busy} onChange={e => setEnabled(e.target.checked)} />
</label>
<div className="ss-grid">
<label className="ss-field">
<span className="ss-setting-name">Загрузка, Мбит/с</span>
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
value={down} disabled={!st || busy} onChange={e => setDown(e.target.value)} />
</label>
<label className="ss-field">
<span className="ss-setting-name">Отдача, Мбит/с</span>
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
</label>
</div>
<p className="node-modal-hint">
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
и деление перестаёт работать.
</p>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
<span className="node-modal-hint">
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
</span>
</span>
<input type="checkbox" checked={auto} disabled={!st || busy || !enabled} onChange={e => setAuto(e.target.checked)} />
</label>
{st?.settings.enabled && (
<div className="ss-status">
<span className={`chip ${st.active ? (st.congested ? 'chip--warm' : 'chip--online') : 'chip--offline'}`}>
<span className="chip-dot" />
{!st.active ? 'не применено' : st.congested ? 'канал занят' : 'канал свободен'}
</span>
<span className="ss-mono">
↓ {st.rateDown} · ↑ {st.rateUp} Мбит/с · устройств {st.devices}
</span>
{st.heavy.length > 0 && (
<span className="ss-mono">в фоне: {st.heavy.join(', ')}</span>
)}
</div>
)}
</>
)}
<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 && tUnsupported) && (
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
Сохранить
</button>
)}
<button className="btn btn--outline" onClick={onClose}>Закрыть</button>
</div>
</div>
</div>
);
}
+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>
);
}
+21
View File
@@ -90,7 +90,28 @@ export const IcoMoon = () => (
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
export const IcoShield = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3z"/><path d="m9 12 2 2 4-4"/>
</svg>
);
export const IcoPulse = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>
</svg>
);
export const IcoMove = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="8" height="16" rx="2"/><rect x="14" y="4" width="8" height="16" rx="2"/><path d="M7 12h10"/><path d="m14 9 3 3-3 3"/>
</svg>
);
export const ICONS: Record<string, () => JSX.Element> = {
users: IcoUsers,
key: IcoKey,
shield: IcoShield,
pulse: IcoPulse,
move: IcoMove,
};
+37
View File
@@ -5,6 +5,9 @@
import axios from 'axios';
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
export type AwgGen = '2' | '3.1';
// Текущая приоритетность ключа/устройства: «background» — канал занят, а он давно качает больше своей доли.
export type Priority = 'normal' | 'background';
export interface User {
name: string;
ip: string;
@@ -15,6 +18,8 @@ export interface User {
lastHandshake: number;
rx?: number;
tx?: number;
priority?: Priority; // нет у awg-ctrl старше деления канала
torrent?: boolean; // пытался качать торренты за последние 10 минут (при включённой блокировке)
}
export interface ServerInfo {
name: string;
@@ -60,9 +65,41 @@ export interface NodeInfo {
online: boolean;
pending: boolean; // нода создана, но ещё ни разу не подключалась
last_seen: number | null;
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
}
// Деление канала (GET/PUT /api/shaping на awg-ctrl сервера).
export interface ShapingStatus {
settings: { enabled: boolean; downMbit: number; upMbit: number; auto: boolean };
active: boolean; // CAKE реально стоит на интерфейсе
congested: boolean;
heavy: string[]; // IP устройств, переведённых в фоновый класс
devices: number; // активных устройств в окне
rateDown: number; // Мбит/с за последний тик
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);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d) return d;
}
return 0;
}
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
// перечитывает данные уже с новым сервером.
+2
View File
@@ -90,3 +90,5 @@ LICENSE file in the root directory of this source tree.
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
.apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; }
+12 -2
View File
@@ -18,6 +18,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в сервер-баре
const load = useCallback(async () => {
try {
@@ -54,12 +55,15 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
const def = getServerId() ?? 0;
const servers = picked ?? [def];
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: servers.includes(def) ? def : servers[0], servers });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setPicked(null);
setCreated({ label: r.label, key: r.key });
await load();
}, [label, token, load, showMsg]);
}, [label, picked, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
@@ -70,6 +74,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
useEffect(() => { if (token) load(); }, [token, load]);
return (
<>
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
@@ -87,6 +92,11 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
</button>
</div>
</div>
<div className="apikey-new-servers">
<span className="apikey-tools-hint">Серверы нового ключа:</span>
<ServerPicker nodes={nodes} selected={picked ?? [getServerId() ?? 0]}
onChange={next => setPicked(next)} />
</div>
<div className="table-card">
<table>
+1
View File
@@ -2,5 +2,6 @@
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"group": "main",
"order": 2
}
+39
View File
@@ -0,0 +1,39 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Аудит». Общая дизайн-система — в App.css. */
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.au-run { margin-left: auto; }
.au-hint { font-size: 13px; color: var(--neutral-text); }
.au-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg);
border-radius: 16px; padding: 10px 16px; margin: 0;
}
.au-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: 12px; }
.au-table td, .au-table th { white-space: nowrap; }
/* мобильный: таблица скрыта (.table-card), то же — карточками */
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.au-card .au-pub { max-width: 100%; }
@media (max-width: 640px) { .au-cards { display: flex; } }
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
.au-run { margin-left: 0; }
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.au-box-title { margin: 0; font-size: 14px; font-weight: 500; }
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.au-setting-name { font-size: 14px; }
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
.au-max { width: 88px !important; }
.au-select { width: auto !important; }
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 13px; }
.au-log .au-pub { max-width: 240px; }
+193
View File
@@ -0,0 +1,193 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoRefresh, IcoTrash } from '../../components/icons';
import { serverName } from '../../components/ServerPicker';
import './audit.css';
// Вкладка «Аудит»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
interface AuditEntry {
server_id: number; status: 'ok' | 'offline' | 'error'; ghosts_checked: boolean;
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number; blocked?: number;
}
interface Settings { clean: boolean; every_h: number; max: number }
interface Cleaned { at: number; server_id: number; pub_key: string; kind: 'ghost' | 'orphan'; key?: string | null; auto: boolean }
interface AuditData { enabled: boolean; settings: Settings; report: { at: number; servers: AuditEntry[] } | null; cleaned: Cleaned[] }
const PERIODS = [1, 3, 6, 12, 24];
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function AuditPage({ token, showMsg }: PageProps) {
const [audit, setAudit] = useState<AuditData | null>(null);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [busy, setBusy] = useState(false);
const [maxEdit, setMaxEdit] = useState('');
const load = useCallback(async () => {
try {
setAudit(await apiFetch('GET', '/ui/masterkeys/audit', token));
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch { showMsg('Ошибка загрузки аудита'); }
}, [token, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
const call = useCallback(async (method: string, path: string, ok: string, body?: object) => {
setBusy(true);
try { setAudit(await apiFetch(method, `/ui/masterkeys/audit${path}`, token, body)); showMsg(ok); }
catch (e) { showMsg(errText(e)); }
finally { setBusy(false); }
}, [token, showMsg]);
const run = () => call('POST', '', 'Аудит выполнен');
const save = (patch: Partial<Settings>) => call('PUT', '/settings', 'Настройки сохранены', patch);
if (!audit) return null;
if (!audit.enabled) return <p className="au-empty">Аудит выключен на этой панели: его проводит только core.</p>;
const { settings } = audit;
const servers = audit.report?.servers ?? [];
const dead = servers.reduce((n, e) => n + e.ghosts.length + e.orphans.length, 0);
const bad = servers.filter(e => e.status !== 'ok');
const confirmed = servers.reduce((n, e) => n + [...e.ghosts, ...e.orphans].filter(p => p.seen_before).length, 0);
const blocked = servers.filter(e => e.blocked);
const rows = servers.flatMap(e => [
...e.ghosts.map(p => ({ e, p, why: 'Есть в ядре, у awg-ctrl записи нет' })),
...e.orphans.map(p => ({ e, p, why: 'Панели не нужен, не снят' })),
]);
const state = (p: AuditPeer) => (
<span className={`chip chip--${p.online ? 'error' : 'offline'}`}>
<span className="chip-dot" />{p.online ? 'Онлайн' : p.lastHandshake ? `был ${timeAgo(p.lastHandshake)} назад` : 'не подключался'}
</span>
);
const confirm = (p: AuditPeer) => (
<span className="au-hint">{p.seen_before ? 'подтверждён' : 'найден впервые — снимется, если останется и в следующий раз'}</span>
);
return (
<>
<section className="au-head">
<span className={`chip chip--${!audit.report ? 'offline' : dead ? 'error' : 'online'}`}>
<span className="chip-dot" />
{!audit.report ? 'Аудит ещё не проводился' : dead ? `Мёртвых пиров: ${dead}` : 'Мёртвых пиров нет'}
</span>
{audit.report && (
<span className="au-hint">
Проверено {timeAgo(audit.report.at)} назад · серверов: {servers.length} · раз в {settings.every_h} ч
</span>
)}
<div className="au-btns">
{confirmed > 0 && (
<button className="btn btn--tonal" disabled={busy}
onClick={() => window.confirm(`Снять с серверов подтверждённых пиров: ${confirmed}?`) && call('POST', '/clean', 'Очистка выполнена')}>
<IcoTrash /> Снять подтверждённых ({confirmed})
</button>
)}
<button className="btn btn--tonal" disabled={busy} onClick={run}>
<IcoRefresh /> {busy ? 'Работаем…' : 'Проверить сейчас'}
</button>
</div>
</section>
<section className="au-box">
<h4 className="au-box-title">Настройки</h4>
<label className="au-setting">
<span className="au-setting-text">
<span className="au-setting-name">Снимать мёртвых автоматически</span>
<span className="au-hint">Только тех, кто мёртв два прохода подряд. Ручной проход ничего не снимает.</span>
</span>
<input type="checkbox" checked={settings.clean} disabled={busy} onChange={e => save({ clean: e.target.checked })} />
</label>
<div className="au-setting">
<span className="au-setting-text">
<label className="au-setting-name" htmlFor="au-every">Период проверки</label>
<span className="au-hint">Как часто core обходит серверы</span>
</span>
<select id="au-every" className="field au-select" value={settings.every_h} disabled={busy}
onChange={e => save({ every_h: Number(e.target.value) })}>
{[...new Set([...PERIODS, settings.every_h])].sort((a, b) => a - b).map(h => <option key={h} value={h}>{h} ч</option>)}
</select>
</div>
<div className="au-setting">
<span className="au-setting-text">
<label className="au-setting-name" htmlFor="au-max">Предел за проход</label>
<span className="au-hint">Если на сервере мёртвых больше — автоочистка ничего не снимает и только сообщает (защита от сбоя)</span>
</span>
<span className="au-setting-ctl">
<input id="au-max" className="field au-max" type="number" min={1} max={1000}
value={maxEdit || String(settings.max)} onChange={e => setMaxEdit(e.target.value)} />
<button className="btn btn--tonal" disabled={busy || !maxEdit || Number(maxEdit) === settings.max}
onClick={() => save({ max: Number(maxEdit) }).then(() => setMaxEdit(''))}>Сохранить</button>
</span>
</div>
</section>
{bad.length > 0 && (
<p className="au-notice">
Не проверены: {bad.map(e => `${serverName(nodes, e.server_id)} (${e.status === 'offline' ? 'не в сети' : 'ошибка'})`).join(', ')}
</p>
)}
{blocked.length > 0 && (
<p className="au-notice">
Автоочистка пропустила: {blocked.map(e => `${serverName(nodes, e.server_id)} (${e.blocked} пиров при пределе ${settings.max})`).join(', ')}. Проверьте список и снимите вручную.
</p>
)}
{servers.some(e => e.status === 'ok' && !e.ghosts_checked) && (
<p className="au-notice">Часть серверов на старой версии awg-ctrl: пиров, которые есть только в ядре, там не видно.</p>
)}
{rows.length > 0 && (
<>
<div className="table-card au-table-card">
<table>
<thead><tr><th>Сервер</th><th>Публичный ключ</th><th>Причина</th><th>Мастер-ключ</th><th>Состояние</th></tr></thead>
<tbody>
{rows.map(({ e, p, why }) => (
<tr key={`${e.server_id}:${p.pub_key}`}>
<td>{serverName(nodes, e.server_id)}</td>
<td className="td-mono"><code className="au-pub" title={p.pub_key}>{p.pub_key}</code></td>
<td>{why}</td>
<td>{p.key ?? '—'}</td>
<td>{state(p)}<br />{confirm(p)}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="au-cards">
{rows.map(({ e, p, why }) => (
<li className="au-card" key={`${e.server_id}:${p.pub_key}`}>
<div className="au-card-top"><b>{serverName(nodes, e.server_id)}</b>{state(p)}</div>
<code className="au-pub" title={p.pub_key}>{p.pub_key}</code>
<span className="au-hint">{why}{p.key ? ` · ${p.key}` : ''}</span>
{confirm(p)}
</li>
))}
</ul>
</>
)}
{audit.cleaned.length > 0 && (
<section className="au-box">
<h4 className="au-box-title">Снято</h4>
<ul className="au-log">
{audit.cleaned.slice(0, 20).map(c => (
<li key={`${c.at}:${c.pub_key}`}>
<span className="au-hint">{timeAgo(c.at)} назад</span>
<span>{serverName(nodes, c.server_id)}</span>
<code className="au-pub" title={c.pub_key}>{c.pub_key}</code>
<span className="au-hint">{c.kind === 'ghost' ? 'был только в ядре' : 'лишний у awg-ctrl'}{c.key ? ` · ${c.key}` : ''} · {c.auto ? 'автоматически' : 'вручную'}</span>
</li>
))}
</ul>
</section>
)}
{audit.report && rows.length === 0 && <p className="au-empty">Всё чисто: лишних пиров на серверах нет.</p>}
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "audit",
"label": "Аудит",
"icon": "shield",
"group": "server",
"order": 1.8,
"chrome": {
"serverBar": false,
"restart": false
}
}
+24 -3
View File
@@ -4,7 +4,7 @@
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — { id, label, icon, order?, chrome? } (манифест: описание + порядок)
// metadata.json — { id, label, icon, group?, order?, chrome? } (манифест: описание, группа меню, порядок)
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
@@ -21,12 +21,20 @@ export interface TabChrome {
restart?: boolean;
}
// Группы меню: порядок здесь = порядок заголовков в сайдбаре. Новая группа — строка сюда.
export const TAB_GROUPS = [
{ id: 'main', label: 'Основное' },
{ id: 'server', label: 'О сервере' },
] as const;
export type TabGroupId = (typeof TAB_GROUPS)[number]['id'];
// Сериализуемая часть из metadata.json.
export interface TabMeta {
id: string;
label: string;
icon: string; // имя иконки → ICONS
order?: number; // порядок в меню (по возрастанию; без него — 0)
group: TabGroupId; // группа меню; без поля — 'main'
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
chrome?: TabChrome;
}
@@ -58,10 +66,15 @@ function toMeta(name: string, raw: unknown): TabMeta {
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
}
const group = (m.group ?? 'main') as string;
if (!TAB_GROUPS.some(g => g.id === group)) {
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
}
return {
id: str('id'),
label: str('label'),
icon: str('icon'),
group: group as TabGroupId,
order: m.order as number | undefined,
chrome: m.chrome as TabChrome | undefined,
};
@@ -80,7 +93,15 @@ export const TABS: TabDef[] = Object.entries(metas)
}
return { ...meta, Icon: ICONS[meta.icon], Page: page };
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// сначала по группе (как в TAB_GROUPS), внутри группы — по order
.sort((a, b) =>
TAB_GROUPS.findIndex(g => g.id === a.group) - TAB_GROUPS.findIndex(g => g.id === b.group)
|| (a.order ?? 0) - (b.order ?? 0));
// Вкладки по группам для меню; пустые группы не показываем.
export const TABS_BY_GROUP = TAB_GROUPS
.map(g => ({ ...g, tabs: TABS.filter(t => t.group === g.id) }))
.filter(g => g.tabs.length > 0);
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
const ids = TABS.map(t => t.id);
@@ -0,0 +1,125 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useMemo } from 'react';
import { apiFetch, genLabel, type NodeInfo } from '../../lib/shared';
// «Серверы для всех»: один набор правок на все мастер-ключи (POST /ui/masterkeys/servers).
// У каждого сервера — «у всех», «ни у кого» или «как есть» (если он стоит только у части ключей).
// Перед применением сервер считает, сколько ключей изменится и какие ключи API потеряют доступ к
// части из них (ключ виден API-ключу, только пока все его серверы в области).
type Pick = 'all' | 'none' | 'keep';
interface Preview { changed: number; total: number; hidden: { id: number; label: string; keys: number }[] }
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function BulkServersModal({ token, nodes, keys, defaults, onClose, onDone }: {
token: string;
nodes: NodeInfo[];
keys: { servers: number[] }[]; // живые мастер-ключи
defaults: number[] | null;
onClose: () => void;
onDone: (changed: number) => void;
}) {
const total = keys.length;
const count = (id: number) => keys.filter(k => k.servers.includes(id)).length;
const initial = (id: number): Pick => { const n = count(id); return n === total && total > 0 ? 'all' : n === 0 ? 'none' : 'keep'; };
const [picks, setPicks] = useState<Record<number, Pick>>(() => Object.fromEntries(nodes.map(n => [n.id, initial(n.id)])));
const [forNew, setForNew] = useState(true);
const [preview, setPreview] = useState<Preview | null>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const { add, remove } = useMemo(() => ({
add: nodes.filter(n => picks[n.id] === 'all' && count(n.id) < total).map(n => n.id),
remove: nodes.filter(n => picks[n.id] === 'none' && count(n.id) > 0).map(n => n.id),
}), [picks, nodes, keys]);
const all = nodes.filter(n => picks[n.id] === 'all').map(n => n.id);
const sameDefaults = JSON.stringify([...all].sort()) === JSON.stringify([...(defaults ?? [])].sort());
const body = { add, remove, ...(forNew && !sameDefaults ? { defaults: all.length ? all : null } : {}) };
const nothing = !add.length && !remove.length && !('defaults' in body);
useEffect(() => {
if (!add.length && !remove.length) { setPreview(null); setError(''); return; }
let live = true;
apiFetch('POST', '/ui/masterkeys/servers', token, { add, remove, dry_run: true })
.then(p => { if (live) { setPreview(p); setError(''); } })
.catch(e => { if (live) { setPreview(null); setError(errText(e)); } });
return () => { live = false; };
}, [token, add, remove]);
useEffect(() => {
const h = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const apply = async () => {
setBusy(true);
try { const r = await apiFetch('POST', '/ui/masterkeys/servers', token, body); onDone(r.changed ?? 0); }
catch (e) { setError(errText(e)); }
finally { setBusy(false); }
};
const option = (id: number, p: Pick, label: string) => (
<button type="button" className={`bs-opt${picks[id] === p ? ' bs-opt--on' : ''}`} aria-pressed={picks[id] === p}
onClick={() => setPicks(prev => ({ ...prev, [id]: p }))}>{label}</button>
);
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal bs-modal" role="dialog" aria-modal="true" aria-label="Серверы для всех ключей" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">Серверы для всех ключей</h3>
<p className="node-modal-hint">
Применится ко всем мастер-ключам сразу ({total}). Пользователям ничего делать не нужно: приложение получит
новый набор серверов при следующем обновлении подписки, ссылки и ключи остаются прежними.
</p>
<div className="bs-list">
{nodes.map(n => {
const c = count(n.id), partial = c > 0 && c < total;
return (
<div className="bs-row" key={n.id}>
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
<span className="chip-dot" />
</span>
<span className="bs-name">
<span className="server-pick-name">{n.health?.server || n.name}</span>
<span className="bs-sub">
{n.health?.ip && <code className="server-pick-ip">{n.health.ip}</code>}
{n.health?.gen && <span>{genLabel(n.health.gen)}</span>}
<span>сейчас у {c} из {total}</span>
</span>
</span>
<span className="bs-opts" role="group" aria-label={`Сервер ${n.name}`}>
{option(n.id, 'all', 'У всех')}
{partial && option(n.id, 'keep', 'Как есть')}
{option(n.id, 'none', 'Ни у кого')}
</span>
</div>
);
})}
</div>
<label className="bs-check">
<input type="checkbox" checked={forNew} onChange={e => setForNew(e.target.checked)} />
<span>Новым ключам — серверы «У всех»{all.length ? '' : ' (не выбрано ни одного — как раньше: все подключённые)'}</span>
</label>
{preview && (
<p className="node-modal-hint">Изменится ключей: {preview.changed} из {preview.total}.</p>
)}
{preview?.hidden.map(h => (
<p className="mk-notice" key={h.id}>
Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области.
Расширьте его область на вкладке «API-ключи», если он ими управляет.
</p>
))}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
<button className="btn btn--outline" onClick={onClose}>Отмена</button>
<button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}>
{busy ? 'Применяем…' : 'Применить'}
</button>
</div>
</div>
</div>
);
}
+203 -44
View File
@@ -1,22 +1,26 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import BulkServersModal from './BulkServersModal';
import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
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;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority; torrent?: boolean;
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
}
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
@@ -30,6 +34,22 @@ const platformLabel = (d: Device): string =>
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
const statusChip = (d: Device) => (
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{d.online ? 'онлайн' : d.lastHandshake ? timeAgo(d.lastHandshake) + ' назад' : 'никогда'}
</span>
);
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом;
// other — адрес с другого сервера, его надо подписать, иначе его примут за адрес выбранного.
const ipPick = (d: Device): { ip: string; serverId?: number; other: boolean } => {
const own = d.ips?.find(x => x.server_id === getServerId());
const pick = own ?? d.ips?.[0];
return { ip: pick?.ip ?? '—', serverId: pick?.server_id, other: !own && !!pick };
};
export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<MasterKey[]>([]);
const [enabled, setEnabled] = useState(true);
@@ -41,18 +61,42 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
const [limitEdit, setLimitEdit] = useState('');
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
const [creating, setCreating] = useState(false);
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
const creatingRef = useRef(false);
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
const [defaults, setDefaults] = useState<number[] | null>(null); // серверы новых ключей из «Серверы для всех»
const [bulk, setBulk] = useState(false);
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const d = await apiFetch('GET', '/ui/masterkeys', token);
const list: MasterKey[] = d.keys ?? [];
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
if (seq !== loadSeq.current) return;
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setDefaults(d.defaults ?? null);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
const all = await Promise.all(list.filter(k => !k.deleting).map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
const live = list.filter(k => !k.deleting);
const res = await Promise.allSettled(live.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token)));
if (seq !== loadSeq.current) return;
const bad = new Set<number>(), unreachable: Record<number, number[]> = {};
const fresh: Record<number, Device[]> = {};
res.forEach((r, i) => {
const id = live[i].id;
if (r.status === 'fulfilled') {
fresh[id] = r.value.devices ?? [];
if (r.value.unreachable?.length) unreachable[id] = r.value.unreachable;
} else bad.add(id);
});
// Не загрузилось — оставляем прежний список (устаревший лучше пустого), но помечаем.
setDevs(prev => Object.fromEntries(live.flatMap(k =>
fresh[k.id] ? [[k.id, fresh[k.id]]] : prev[k.id] ? [[k.id, prev[k.id]]] : [])));
setFailed(bad); setDown(unreachable);
} catch { if (seq === loadSeq.current) showMsg('Ошибка загрузки мастер-ключей'); }
}, [token, showMsg]);
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
@@ -62,16 +106,20 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [load, showMsg]);
const create = useCallback(async () => {
if (!label.trim()) return;
await act(async () => {
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
if (!label.trim() || creatingRef.current) return;
creatingRef.current = true; setCreating(true);
try { await act(async () => {
// Новый ключ — с серверами из «Серверы для всех» (их подставит панель), а не задано — со всеми,
// которые уже подключались; набор потом правится в настройках ключа.
const ready = nodes.filter(n => !n.pending).map(n => n.id);
await apiFetch('POST', '/ui/masterkeys', token, {
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
label: label.trim(), device_limit: Number(limit), ...(!defaults && ready.length ? { servers: ready } : {}),
});
setLabel('');
}, 'Мастер-ключ создан');
}, [label, limit, nodes, token, act]);
}, 'Мастер-ключ создан'); }
finally { creatingRef.current = false; setCreating(false); }
}, [label, limit, nodes, defaults, token, act]);
const live = useMemo(() => keys.filter(k => !k.deleting), [keys]);
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
@@ -103,6 +151,29 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]);
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
// поэтому сперва спросить и назвать, кого именно. Список устройств не загрузился — считаем по
// счётчику ключа и имён не называем, но спросить всё равно надо.
const saveLimit = useCallback(async (k: MasterKey) => {
const limit = Number(limitEdit);
const list = devs[k.id];
const have = list?.length ?? k.devices;
const extra = have - limit;
if (Number.isInteger(limit) && extra > 0) {
const who = list
? [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
.map(d => `«${d.device_name || d.id}»`).join(', ')
: `${extra} шт.`;
if (!confirm(`Лимит ${limit} меньше, чем устройств (${have}). Будут отвязаны подключившиеся последними: ${who}. Продолжить?`)) return;
}
try {
const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit });
const n: number = r.unbound?.length ?? 0;
showMsg(n ? `Лимит сохранён, отвязано устройств: ${n}` : 'Лимит сохранён');
await load();
} catch (e) { showMsg(errText(e)); }
}, [limitEdit, devs, token, load, showMsg]);
const fold = (id: number) => setFolded(prev => {
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
});
@@ -138,6 +209,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
{/* Слева — настройки самого ключа */}
<section className="mk-box">
<h4 className="mk-box-title">Настройки ключа</h4>
<div className="mk-setting">
<div className="mk-setting-text">
<span className="mk-setting-name">UUID</span>
<code className="mk-uuid" title={k.uuid}>{k.uuid}</code>
<span className="mk-setting-hint">Постоянный: перевыпуск ссылки его не меняет</span>
</div>
<div className="mk-setting-ctl">
<button className="btn btn--tonal" onClick={() =>
copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
<IcoCopy /> Скопировать
</button>
</div>
</div>
<div className="mk-setting">
<div className="mk-setting-text">
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
@@ -146,8 +230,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<div className="mk-setting-ctl">
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
<button className="btn btn--tonal" onClick={() =>
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: Number(limitEdit) }), 'Лимит сохранён')}>
<button className="btn btn--tonal" onClick={() => saveLimit(k)}>
Сохранить
</button>
</div>
@@ -221,6 +304,60 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div>
);
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
// по клику копируется.
const nameCell = (k: MasterKey) => (
<span className="name-stack">
{k.label}
<span className="tip-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать UUID ${k.uuid}`}
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
{k.uuid.slice(0, 8)}…
</button>
</span>
</span>
);
// Данные по ключу неполные: устройства не загрузились или сервер не ответил на статистику.
const dataFlag = (k: MasterKey) => {
if (failed.has(k.id)) return <span className="mk-flag" title="Список устройств не загрузился — показан последний известный"> · устройства не загрузились</span>;
const d = down[k.id];
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
};
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
const summary = (list: Device[]) => {
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>;
const on = list.filter(d => d.online).length;
const last = Math.max(...list.map(d => d.lastHandshake || 0));
const torrent = list.some(d => d.torrent);
const prio = list.some(d => d.priority === 'background') ? 'background' : list.find(d => d.priority)?.priority;
return (
<>
<td>
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
</span>
</td>
<td><span className="user-chips"><PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} /></span></td>
<td colSpan={2}></td>
<td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td>
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
</>
);
};
// Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного.
const ipCell = (d: Device) => {
const all = d.ips ?? [];
const { ip, serverId, other } = ipPick(d);
const text = <code className="mk-ip">{ip}{other && serverId !== undefined ? ` · ${serverName(nodes, serverId)}` : ''}</code>;
return all.length > 1
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
: text;
};
const caret = (k: MasterKey) => (
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
@@ -235,12 +372,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
</span>
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
<span className="mk-device-sub">{platformLabel(d)}
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
</div>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
{statusChip(d)}
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
<div className="mk-device-ip">
<IcoGlobe />
{ipCell(d)}
<PriorityChip priority={d.priority} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
</div>
<div className="mk-device-traffic">
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
@@ -265,7 +407,9 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств"
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={create}><IcoPlus /> Создать ключ</button>
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
<button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length}
title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button>
</div>
</div>
@@ -274,8 +418,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<table className="mk-table">
<thead>
<tr>
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th>
<th>↓ rx</th><th>↑ tx</th><th>Был</th><th>Устройства</th><th></th>
<th>Имя</th><th>Статус</th><th>Приоритет</th><th>IP</th><th>Платформа</th>
<th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
</tr>
</thead>
<tbody>
@@ -284,37 +428,46 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
) : keys.map(k => {
if (k.deleting) return (
<tr key={k.id} className="mk-tr-master mk-last">
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
<td colSpan={8}>{deletingChip(k)}</td>
</tr>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const open = !folded.has(k.id);
return (
<Fragment key={k.id}>
<tr className="mk-tr-master">
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
<td colSpan={6}></td>
<td>{meter(k, list.length)}</td>
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
{summary(list)}
<td>{meter(k, count)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td>
</tr>
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
{open && list.map((d, i) => (
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
<td><span className="mk-indent">{d.device_name || 'Устройство'}</span></td>
<td className="td-mono"><code className="mk-device-id" title={d.device_id}>{d.device_id}</code></td>
<td>{platformLabel(d)}</td>
<td>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
<span className="mk-indent">
<span className="name-stack">
{d.device_name || 'Устройство'}
<span className="tip-wrap" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
{d.device_id.slice(0, 8)}…
</button>
</span>
</span>
</span>
</td>
<td className="td-mono">{bytes(d.rx)}</td>
<td className="td-mono">{bytes(d.tx)}</td>
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td>
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}
</td>
<td>{statusChip(d)}</td>
<td><span className="user-chips"><PriorityChip priority={d.priority} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
<td className="td-mono">{ipCell(d)}</td>
<td>{platformLabel(d)}</td>
<td>{bytes(d.rx)}</td>
<td>{bytes(d.tx)}</td>
<td></td>
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr>
@@ -339,21 +492,22 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<li className="mk-root" key={k.id}>
<div className="mk-master">
<IcoKey />
<span className="mk-master-label">{k.label}</span>
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
{deletingChip(k)}
</div>
</li>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const isFolded = folded.has(k.id);
return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
{caret(k)}
<IcoKey />
<span className="mk-master-label">{k.label}</span>
{meter(k, list.length)}
<span className="mk-master-label">{k.label}{dataFlag(k)}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, count)}
{actions(k)}
</div>
{tools === k.id && toolbox(k)}
@@ -371,6 +525,11 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</ul>
)}
{bulk && (
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
)}
{modal && (
<div className="qr-backdrop" onClick={() => setModal(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
+50
View File
@@ -26,6 +26,12 @@ LICENSE file in the root directory of this source tree.
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
.mk-tr-master:hover td { background: var(--surface-container); }
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
.mk-master-uuid {
display: block;
font-family: var(--mono); font-size: 12px; font-weight: 400;
color: var(--neutral-text);
overflow: hidden; text-overflow: ellipsis;
}
.mk-name .mk-caret { margin-left: -8px; }
.mk-tr-master .mk-name { color: var(--on-surface); }
.mk-tr-master .mk-name svg { color: var(--primary); }
@@ -33,6 +39,9 @@ LICENSE file in the root directory of this source tree.
.mk-tr-tools:hover { background: transparent; }
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: 12px; }
/* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */
.mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; }
.mk-ip { font-family: var(--mono); }
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
@@ -111,6 +120,10 @@ LICENSE file in the root directory of this source tree.
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mk-uuid {
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
@@ -193,6 +206,13 @@ LICENSE file in the root directory of this source tree.
}
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
.mk-device-ip {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
width: 100%;
color: var(--neutral-text);
}
.mk-device-ip .mk-ip { font-size: 13px; }
.mk-device-ip .tip-wrap { width: auto; }
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
.mk-metric { display: inline-flex; gap: 4px; }
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
@@ -259,3 +279,33 @@ LICENSE file in the root directory of this source tree.
.mk-free { padding: 12px; font-size: 13px; }
}
/* «Серверы для всех» (BulkServersModal) */
.bs-modal { max-width: 620px; }
.bs-list { display: flex; flex-direction: column; gap: 8px; }
.bs-row {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container); border-radius: 12px; padding: 8px 8px 8px 14px;
font-size: 13px; color: var(--on-surface);
}
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--neutral-text); }
.bs-sub .server-pick-ip { margin-left: 0; }
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
.bs-opt {
min-height: 36px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill);
cursor: pointer; white-space: nowrap;
}
.bs-opt:hover { background: var(--state-primary); }
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.bs-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--on-surface); cursor: pointer; }
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
@media (max-width: 640px) {
.bs-row { flex-wrap: wrap; }
.bs-opts { width: 100%; }
.bs-opt { flex: 1; }
}
.bs-modal .btn:disabled { opacity: .5; cursor: default; }
+1
View File
@@ -2,5 +2,6 @@
"id": "masterkeys",
"label": "Мастер-ключи",
"icon": "key",
"group": "main",
"order": 1.5
}
+229
View File
@@ -0,0 +1,229 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, type PageProps } from '../../lib/shared';
import { IcoCopy } from '../../components/icons';
import './migrate.css';
// Вкладка «Перенос»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
type MoveState = '' | 'sending' | 'moved' | 'uncertain' | 'imported';
// Новая панель после приёма: кто уже знает её адрес — тогда старый сервер можно выключать.
interface Retire {
since: number;
home: string | null; // адрес хаба для нод; null — не задан SUB_HOST
old: { exists: boolean; online: boolean };
devices: { total: number; fresh: number }; // fresh — получили конфиг после переноса
unused_links: number; // мастер-ключи без устройств: в ссылке старый адрес
nodes: { id: number; name: string; online: boolean; ok: boolean }[];
}
interface MigrateInfo {
state: MoveState;
error: string | null;
target: string | null; // старая: адрес новой панели
code: { code: string; expires_in: number } | null;
export?: string | null; // null — можно, строка — почему нельзя
accept?: string | null;
retire?: Retire | null;
}
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function MigratePage({ token, showMsg }: PageProps) {
const [info, setInfo] = useState<MigrateInfo | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
try { setInfo(await apiFetch('GET', '/ui/migrate', token)); }
catch { /* панель перезапускается — следующий опрос покажет */ }
}, [token]);
useEffect(() => { if (token) load(); }, [token, load]);
// Пока висит код — ждём, когда старая панель пришлёт архив; после приёма панель
// перезапускается, и страница перечитывается уже с перенесённой базой.
const waiting = !!info?.code || info?.state === 'imported' || !!info?.retire;
useEffect(() => {
if (!waiting) return;
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [waiting, load]);
useEffect(() => {
if (info?.state !== 'imported' || info.error) return;
const t = setTimeout(() => window.location.reload(), 8000);
return () => clearTimeout(t);
}, [info?.state, info?.error]);
const call = async (method: string, path: string, body?: object) => {
setBusy(true); setError('');
try { setInfo(await apiFetch(method, `/ui/migrate${path}`, token, body)); }
catch (e) { setError(errText(e)); }
finally { setBusy(false); }
};
const send = async () => {
if (!window.confirm('Перенести панель? Здесь она остановится, а этот сервер станет нодой новой панели.')) return;
setBusy(true); setError('');
try {
await apiFetch('POST', '/ui/migrate/export', token, { code: code.trim() });
await load();
} catch (e) { setError(errText(e)); await load(); }
finally { setBusy(false); }
};
if (!info) return null;
if (info.state === 'moved') return (
<section className="mv-box">
<span className="chip chip--online mv-self"><span className="chip-dot" />Панель перенесена</span>
<p className="mv-text">
Новая панель: <a href={info.target ?? '#'}>{info.target}</a>. Через несколько секунд этот сервер станет её
нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать,
а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.
</p>
{info.target && <a className="btn btn--primary mv-self" href={info.target}>Открыть новую панель</a>}
</section>
);
if (info.state === 'imported') return (
<section className="mv-box">
<span className={`chip chip--${info.error ? 'error' : 'online'} mv-self`}><span className="chip-dot" />
{info.error ? 'Приём прерван' : 'Панель принята'}</span>
<p className="mv-text">
{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}
</p>
</section>
);
const r = info.retire;
const retireBox = r && (() => {
const devicesOk = r.devices.fresh >= r.devices.total;
const nodesOk = r.nodes.every(n => n.ok);
const ready = devicesOk && nodesOk && !!r.home;
const chip = (ok: boolean, text: string, offline = false) => (
<span className={`chip chip--${ok ? 'online' : offline ? 'offline' : 'warm'}`}><span className="chip-dot" />{text}</span>
);
return (
<section className="mv-box" key="retire">
<h4 className="mv-title">Отключение старого сервера</h4>
<p className="mv-text">
{r.old.exists
? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его
можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними.</>
: <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.</>}
</p>
<div className="mv-checks">
<div className="mv-check">
{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)}
<span>устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}</span>
</div>
{r.nodes.map(n => (
<div className="mv-check" key={n.id}>
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)}
<span>нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}</span>
</div>
))}
{r.unused_links > 0 && (
<div className="mv-check">
{chip(false, String(r.unused_links), true)}
<span>мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на
вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же</span>
</div>
)}
</div>
{!r.home && <p className="mv-notice">Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.</p>}
<ol className="mv-steps">
<li>Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.</li>
<li>Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.</li>
<li>Удалите сервер «#0» и выключите старую машину.</li>
</ol>
{!r.old.exists && (
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('DELETE', '/retire')}>Готово, скрыть</button>
)}
</section>
);
})();
const canExport = info.export === null, canAccept = info.accept === null;
const exportBox = (
<section className="mv-box" key="export">
<h4 className="mv-title">Перенести эту панель на другой сервер</h4>
{canExport ? (
<>
<p className="mv-text">
Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели.
Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.
</p>
<ol className="mv-steps">
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
<li>Там на вкладке «Перенос» нажмите «Получить код».</li>
<li>Вставьте код сюда и нажмите «Перенести».</li>
</ol>
{info.state === 'uncertain' ? (
<>
<p className="mv-notice">{info.error}</p>
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('POST', '/cancel')}>
Отменить перенос
</button>
</>
) : (
<div className="mv-row">
<input className="field" placeholder="awgmove://…" value={code} disabled={busy}
onChange={e => setCode(e.target.value)} />
<button className="btn btn--primary" disabled={busy || !code.trim().startsWith('awgmove://')} onClick={send}>
{busy ? 'Переносим…' : 'Перенести'}
</button>
</div>
)}
</>
) : <p className="mv-text mv-muted">{info.export}</p>}
</section>
);
const acceptBox = (
<section className="mv-box" key="accept">
<h4 className="mv-title">Принять панель с другого сервера</h4>
{canAccept ? (
info.code ? (
<>
<p className="mv-text">
Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
</p>
<code className="node-modal-cmd">{info.code.code}</code>
<div className="mv-row mv-row--end">
<button className="btn btn--tonal" onClick={() => copyText(info.code!.code).then(() => showMsg('Код скопирован'))}>
<IcoCopy /> Скопировать
</button>
<button className="btn btn--outline" disabled={busy} onClick={() => call('DELETE', '/code')}>Отозвать</button>
</div>
<p className="mv-text mv-muted">Ждём архив от старой панели…</p>
</>
) : (
<>
<p className="mv-text">
Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней
нодой #0. Логин и пароль останутся этой панели.
</p>
<button className="btn btn--primary mv-self" disabled={busy}
onClick={() => call('POST', '/code', { host: window.location.hostname })}>
Получить код
</button>
</>
)
) : <p className="mv-text mv-muted">{info.accept}</p>}
</section>
);
return (
<>
{/* Доступное действие — первым. */}
{retireBox}
{canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]}
{error && <p className="mv-notice">{error}</p>}
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "migrate",
"label": "Перенос",
"icon": "move",
"group": "server",
"order": 1.9,
"chrome": {
"serverBar": false,
"restart": false
}
}
+26
View File
@@ -0,0 +1,26 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.mv-title { margin: 0; font-size: 14px; font-weight: 500; }
.mv-text { margin: 0; font-size: 14px; line-height: 1.5; }
.mv-muted { font-size: 13px; color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg);
border-radius: 16px; padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 640px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-box .btn:disabled { opacity: .5; cursor: default; }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: 14px; line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
+361
View File
@@ -0,0 +1,361 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoCopy, IcoTrash } from '../../components/icons';
import LineChart from '../../components/LineChart';
import './monitor.css';
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
// Кнопка «Подключить Prometheus» просит локальный awg-ctrl поставить его (`awg-ctrl monitor install`, root;
// сама панель пакетов не ставит). Графики читают Prometheus через фиксированные запросы панели
// (awg-ui/monitor.ts).
interface Status {
ready: boolean; can_install: boolean; command: string;
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
}
type Pts = [number, number][];
interface Overview {
range: string; step: number; from: number; to: number;
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 часа'], ['7d', '7 дней'], ['30d', '30 дней']];
const nf = (v: number, d = 1) => v.toLocaleString('ru-RU', { maximumFractionDigits: d });
// Скорость в битах/с → «12,4 Мбит/с»
function rate(v: number, d = 1): string {
if (v >= 1e9) return `${nf(v / 1e9, d)} Гбит/с`;
if (v >= 1e6) return `${nf(v / 1e6, d)} Мбит/с`;
if (v >= 1e3) return `${nf(v / 1e3, d)} Кбит/с`;
return `${nf(v, 0)} бит/с`;
}
const lastOf = (p: Pts) => (p.length ? p[p.length - 1][1] : null);
// Полоска нагрузки: цвет заливки — серьёзность (норма → внимание → опасно), дорожка — светлая ступень той же шкалы.
// Цифра рядом всегда есть: цвет не единственный носитель смысла.
const level = (v: number) => (v >= 90 ? 'mon-bar--bad' : v >= 70 ? 'mon-bar--warn' : '');
function Bar({ v, label }: { v: number | null | undefined; label: string }) {
const pct = v == null ? 0 : Math.min(100, Math.max(0, v));
return (
<span className="mon-bar" role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100}
aria-valuenow={v == null ? undefined : Math.round(pct)}>
<i className={v == null ? '' : level(pct)} style={{ width: `${pct}%` }} />
</span>
);
}
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
function JobProgress({ job, starting }: { job: Status['job']; starting: boolean }) {
let step: [number, number, string] | null = null;
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
const failed = !!job && job.exit !== null && job.exit !== 0;
const done = !!job && job.exit === 0;
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
const label = failed ? `Не удалось (код ${job!.exit})`
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
return (
<div className="mon-progress" aria-live="polite">
<span className={`mon-pbar${failed ? ' mon-pbar--bad' : done ? ' mon-pbar--ok' : !step && (starting || job?.running) ? ' mon-pbar--wait' : ''}`}
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
<i style={{ width: `${pct}%` }} />
</span>
<span className="mon-progress-label">{label}</span>
</div>
);
}
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);
const [range, setRange] = useState('1h');
const [ov, setOv] = useState<Overview | null>(null);
const [ovErr, setOvErr] = useState('');
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена из этой вкладки
const [server, setServer] = useState<NodeInfo | null>(null); // показанный сервер: выбран в другой вкладке, здесь не меняется
const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', '/ui/monitor', token) as Status; if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token]);
const loadOverview = useCallback(async () => {
setLoading(true);
try {
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}&server=${getServerId() ?? 0}`, token) as Overview;
if (alive.current) { setOv(o); setOvErr(''); }
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
finally { if (alive.current) setLoading(false); }
}, [token, range]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
// Название и состояние сервера для заголовка; список серверов на этой вкладке не показываем
useEffect(() => {
const load = () => apiFetch('GET', '/ui/nodes', token)
.then(r => { if (alive.current) setServer((r.nodes as NodeInfo[]).find(n => n.id === (getServerId() ?? 0)) ?? null); })
.catch(() => { /* заголовок необязателен */ });
void load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, [token]);
// Пока Prometheus готов — обновляем графики; пока ставится — следим за ходом установки.
useEffect(() => {
if (!status?.ready) return;
void loadOverview();
const t = setInterval(() => { void loadOverview(); }, 15000);
return () => clearInterval(t);
}, [status?.ready, loadOverview]);
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог
useEffect(() => {
if (!pending && !status?.job?.running) return;
const t = setInterval(async () => {
const s = await loadStatus();
if (s && !s.job?.running) setPending(null);
}, 2000);
return () => clearInterval(t);
}, [pending, status?.job?.running, loadStatus]);
const run = async (action: 'install' | 'remove') => {
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' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
const working = !!pending || !!status?.job?.running;
// Прогресс рядом с кнопкой: для установки — пока Prometheus нет (там же виден итог удаления), для удаления — только удаление
const progressFor = (action: 'install' | 'remove' | 'any') => {
const j = status?.job;
const mine = !!j && (action === 'any' || j.action === action) && (j.running || j.exit !== null);
const starting = !!pending && (action === 'any' || action === pending);
return mine || starting ? <JobProgress job={mine ? j! : null} starting={starting} /> : null;
};
// Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt
const failureLog = () => {
const j = status?.job;
if (!j || j.exit === null || j.exit === 0) return null;
return (
<>
<pre className="mon-log">{j.log.trim()}</pre>
<p className="login-error">
{j.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${j.action}`}</b>
</p>
</>
);
};
// ───────── установка ─────────
const setup = status && !status.ready && (
<section className="mon-card" aria-labelledby="mon-setup">
<div className="mon-head">
<h3 className="mon-title" id="mon-setup">Мониторинг</h3>
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span>
</div>
<p className="mon-hint">
Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики —
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
только 127.0.0.1, наружу он не открывается.
</p>
{status.can_install ? (
<div className="mon-actions">
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
{pending === 'install' || (status.job?.running && status.job.action === 'install') ? 'Устанавливаем…' : 'Подключить Prometheus'}
</button>
{progressFor('any')}
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):</p>
<code className="mon-code">{status.command}</code>
<div className="mon-actions">
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
</div>
</>
)}
{failureLog()}
</section>
);
// Удаление: только пока Prometheus есть
const manage = status?.ready && (
<section className="mon-card" aria-labelledby="mon-manage">
<div className="mon-head">
<h3 className="mon-title" id="mon-manage">Prometheus</h3>
<span className="chip chip--online"><span className="chip-dot" />работает</span>
</div>
<p className="mon-hint">
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет
пакет и сотрёт накопленные данные.
</p>
{status.can_install ? (
<div className="mon-actions">
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
<IcoTrash /> {pending === 'remove' || (status.job?.running && status.job.action === 'remove') ? 'Удаляем…' : 'Удалить Prometheus'}
</button>
{progressFor('remove')}
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере от root:</p>
<code className="mon-code">awg-ctrl monitor remove</code>
</>
)}
{status.job?.action === 'remove' && failureLog()}
</section>
);
// ───────── дашборд ─────────
// Вкладка показывает один сервер — выбранный в сервер-баре; при смене сервера App монтирует её заново.
const one = ov?.servers[0];
const data = ov?.total ?? null;
const cur = data && {
// Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
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 };
const dashboard = status?.ready && (
<>
{/* Период — единственный фильтр: он задаёт окно для всего ниже */}
<div className="mon-filters" role="group" aria-label="Период">
{RANGES.map(([id, label]) => (
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
))}
</div>
{ovErr && <p className="login-error">{ovErr}</p>}
{ov && !one?.ok && (
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
)}
<div className="mon-tiles">
<div className="mon-tile">
<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>
{/* Точки окрашены как серии графика «Устройства» ниже */}
<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>
<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>
{ov && data && (
<div className="mon-charts">
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => rate(v)} formatAxis={v => nf(v / unit.div, unit.div > 1 && v / unit.div < 10 ? 1 : 0)}
series={[
{ id: 'down', name: 'загрузка', color: 'var(--series-1)', points: data.down },
{ id: 'up', name: 'отдача', color: 'var(--series-2)', points: data.up },
]} />
<LineChart title="Процессор и память, %" from={ov.from} to={ov.to} step={ov.step} dim={loading} max={100}
format={v => `${nf(v, 0)} %`}
series={[
{ id: 'cpu', name: 'процессор', color: 'var(--series-1)', points: data.cpu },
{ id: 'mem', name: 'память', color: 'var(--series-2)', points: data.mem },
]} />
<LineChart title="Устройства" from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => nf(v, 0)}
series={[
{ id: 'online', name: 'онлайн', color: 'var(--series-1)', points: data.online },
{ id: 'heavy', name: 'в фоне', color: 'var(--series-2)', points: data.heavy },
]} />
<LineChart title="Отброшено очередью, пакетов/с" from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => nf(v, 1)}
series={[{ id: 'drops', name: 'отброшено', color: 'var(--series-1)', points: data.drops }]} />
</div>
)}
</>
);
return (
<>
{server && (
<p className="mon-server">
<span className="mon-server-label">Сервер</span>
<b>{server.health?.server || server.name}</b>
<span className={`chip chip--${server.online ? 'online' : 'offline'}`}><span className="chip-dot" />{server.online ? 'на связи' : 'нет связи'}</span>
</p>
)}
{!status && <p className="mon-hint">Загрузка…</p>}
{setup}
{dashboard}
{manage}
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "monitor",
"label": "Монитор",
"icon": "pulse",
"group": "server",
"order": 1.7,
"chrome": {
"serverBar": false,
"restart": false
}
}
+148
View File
@@ -0,0 +1,148 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Монитор». Общая дизайн-система — в App.css. */
.mon-card {
display: flex; flex-direction: column; gap: 12px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 20px;
margin-bottom: 16px;
}
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.mon-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
.mon-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.mon-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mon-code {
display: block; word-break: break-all; user-select: all;
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
background: var(--surface-container);
border-radius: var(--r-table);
padding: 12px;
}
@media (max-width: 640px) {
.mon-actions .btn { flex: 1; }
}
/* ───── Графики ─────
Цвета рядов — токены темы. Проверено validate_palette: светлая #6a45b8 / #c4620a на #fdfaf6,
тёмная #8f6be0 / #d9772a на #2b251d (CVD ΔE ≥ 19, контраст ≥ 3:1). Подписи и значения цветом ряда не красим. */
:root { --series-1: #6a45b8; --series-2: #c4620a; }
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.mon-range {
min-height: 44px; padding: 0 18px;
font: inherit; font-size: 14px; color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill);
cursor: pointer;
}
.mon-range:hover { background: var(--state-primary); }
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
.mon-tile {
display: flex; flex-direction: column; gap: 4px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-table);
padding: 14px 16px;
}
.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) {
.mon-charts { grid-template-columns: 1fr 1fr; }
.mon-charts .mon-chart:first-child { grid-column: 1 / -1; }
}
.mon-chart {
margin: 0; min-width: 0;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-card);
padding: 16px;
transition: opacity .15s;
}
.mon-chart--dim { opacity: .6; }
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
.mon-chart-title { font-size: 15px; font-weight: 500; color: var(--on-surface); }
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--neutral-text); }
.mon-legend-item { display: inline-flex; align-items: center; gap: 6px; }
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: 2px; flex: none; }
.mon-chart-toggle {
margin-left: auto; min-height: 44px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-primary-container);
background: none; border: 0; border-radius: var(--r-pill); cursor: pointer;
}
.mon-chart-toggle:hover { background: var(--state-primary); }
.mon-plot { position: relative; }
.mon-plot svg { display: block; max-width: 100%; }
.mon-plot svg:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 8px; }
.mon-grid { stroke: var(--outline-variant); stroke-width: 1; }
.mon-tick { fill: var(--neutral-text); font-size: 12px; font-family: var(--font); }
.mon-cross { stroke: var(--outline); stroke-width: 1; }
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: 13px; color: var(--neutral-text); }
.mon-tip {
position: absolute; pointer-events: none; z-index: 2;
display: flex; flex-direction: column; gap: 4px;
background: var(--surface-container);
border: 1px solid var(--outline-variant); border-radius: 12px;
padding: 8px 10px; font-size: 12px; color: var(--neutral-text);
}
.mon-tip-time { color: var(--neutral-text); }
.mon-tip-row { display: flex; align-items: center; gap: 8px; }
.mon-tip-row b { font-weight: 500; font-size: 13px; color: var(--on-surface); }
.mon-table-wrap { overflow-x: auto; }
.mon-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.mon-table th { text-align: left; font-weight: 500; color: var(--label); padding: 8px 10px; white-space: nowrap; }
.mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mon-log {
margin: 0; max-height: 240px; overflow: auto;
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
background: var(--surface-container); border-radius: var(--r-table);
padding: 12px; white-space: pre-wrap; word-break: break-word;
}
.mon-notice { margin-bottom: 12px; }
/* Полоски процессора и памяти в плитках */
.mon-bar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); margin: 2px 0; }
.mon-bar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .3s; }
.mon-bar i.mon-bar--warn { background: var(--warn-dot); }
.mon-bar i.mon-bar--bad { background: var(--error-dot); }
/* Какой сервер показан (выбирается в других вкладках) */
.mon-server { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 12px; font-size: 16px; color: var(--on-surface); }
.mon-server b { font-weight: 500; }
.mon-server-label { font-size: 12px; color: var(--neutral-text); }
/* Прогресс операции рядом с кнопкой */
.mon-actions { align-items: center; }
.mon-progress { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
.mon-progress-label { font-size: 12px; color: var(--neutral-text); }
.mon-pbar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); }
.mon-pbar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .4s ease; }
.mon-pbar--ok i { background: var(--success-dot); }
.mon-pbar--bad i { background: var(--error-dot); }
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
.mon-pbar--wait i { width: 35% !important; animation: mon-wait 1.2s ease-in-out infinite; }
@keyframes mon-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
@media (prefers-reduced-motion: reduce) { .mon-pbar--wait i { animation: none; } .mon-pbar i { transition: none; } }
+8 -2
View File
@@ -8,6 +8,8 @@ import {
type User, type PageProps, type AwgGen,
} 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';
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
@@ -28,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 } : 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 */ }
}, []);
@@ -162,6 +164,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<tr>
<th>#</th>
<th>Статус</th>
<th>Приоритет</th>
<th>Имя</th>
<th>IP</th>
<th>↓ rx</th>
@@ -171,7 +174,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
</thead>
<tbody>
{users.length === 0 ? (
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr>
<tr><td colSpan={8} className="empty">Нет пользователей</td></tr>
) : users.map((u, i) => (
<tr key={u.name}>
<td className="td-num">{i + 1}</td>
@@ -185,6 +188,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
: 'никогда'}
</span>
</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 && (
@@ -251,6 +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} torrent={u.torrent} />
<TorrentChip torrent={u.torrent} />
</div>
<div className="user-card-metrics">
<div className="metric-block">
+1
View File
@@ -2,5 +2,6 @@
"id": "users",
"label": "Пользователи",
"icon": "users",
"group": "main",
"order": 1
}
+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; }
+243 -17
View File
@@ -51,7 +51,7 @@ export interface Servers {
}
interface MasterRow {
id: number; label: string; secret: Buffer; device_limit: number;
id: number; uuid: string; label: string; secret: Buffer; device_limit: number;
server_id: number; created_at: number; revoked_at: number | null;
}
interface DeviceRow {
@@ -72,6 +72,8 @@ const TS_WINDOW = 300; // секунд, ±
const DEFAULT_LIMIT = 3;
const MAX_SERVERS = 32; // серверов в одном мастер-ключе
const SYNC_EVERY = 60_000; // фоновое сведение серверов
const AUDIT_HOUR = 3_600_000;
const AUDIT_FIRST = 120_000; // первый аудит — вскоре после старта, когда ноды подключились
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
@@ -195,6 +197,16 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// таблицы колонки может не быть.
if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version"))
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
// uuid мастер-ключа — постоянный идентификатор: не меняется ни при перевыпуске ссылки,
// ни при правке метки. Существующим ключам выдаётся здесь один раз.
if (!(uidb.prepare("PRAGMA table_info(master_keys)").all() as { name: string }[]).some(c => c.name === "uuid"))
uidb.exec("ALTER TABLE master_keys ADD COLUMN uuid TEXT");
{
const setUuid = uidb.prepare<[string, number]>("UPDATE master_keys SET uuid = ? WHERE id = ?");
for (const { id } of uidb.prepare("SELECT id FROM master_keys WHERE uuid IS NULL").all() as { id: number }[])
setUuid.run(crypto.randomUUID(), id);
}
uidb.exec("CREATE UNIQUE INDEX IF NOT EXISTS master_keys_uuid ON master_keys (uuid)");
uidb.pragma("foreign_keys = ON");
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
@@ -222,7 +234,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
masterRow: uidb.prepare<[number]>("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE id = ?"),
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
masterInsert: uidb.prepare<[string, string, Buffer, number, number, number]>("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)"),
masterIdByUuid: uidb.prepare<[string]>("SELECT id FROM master_keys WHERE uuid = ?"),
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
masterRevoke: uidb.prepare<[number, number]>("UPDATE master_keys SET revoked_at = ? WHERE id = ?"),
@@ -396,6 +409,161 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
q.revokedGone.run();
}
// ── Аудит серверов ─────────────────────────────────────────────────────
// Периодически (по умолчанию раз в час) core обходит все серверы и ищет «мёртвых»: пиров, которые живы на сервере, хотя
// панель про них не знает. Сообщает и — если включена очистка (настройки аудита) или оператор нажал кнопку — снимает подтверждённых: мёртвых два прохода подряд.
// ghosts — есть в ядре (awg show), но неизвестны awg-ctrl: строку удалили, а `awg set … remove`
// не сработал, и никто про такого пира уже не вспомнит;
// orphans — есть у awg-ctrl в области этой панели, но панели не нужны (sync их не снял);
// key — мастер-ключ пира, если он отозван или удалён.
// Пиры чужой области (другая панель, пиры до областей) в отчёт не попадают — считаются в foreign.
// seen_before — «мёртвый» и в прошлом проходе тоже: только такие снимает очистка (пир, которого
// прямо сейчас добавляют или удаляют, за два прохода подряд мёртвым не окажется).
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
interface AuditEntry {
server_id: number; status: "ok" | "offline" | "error";
ghosts_checked: boolean; // false — awg-ctrl старше /api/audit, «призраков» не видно
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number;
blocked?: number; // очистка отказалась снимать столько пиров разом (предохранитель)
}
interface AuditReport { at: number; servers: AuditEntry[] }
interface CleanedPeer { at: number; server_id: number; pub_key: string; kind: "ghost" | "orphan"; key?: string | null; auto: boolean }
// Настройки аудита (панель, вкладка «Аудит»). clean — снимать «мёртвых» сами; every_h — период;
// max — предохранитель: больше пиров на сервере за проход не снимаем, только сообщаем.
interface AuditSettings { clean: boolean; every_h: number; max: number }
const AUDIT_DEFAULTS: AuditSettings = { clean: false, every_h: 1, max: 10 };
const AUDIT_ON = (process.env.AUDIT ?? "on").toLowerCase() !== "off"; // выключен у ноды со своей панелью
const metaGet = <T,>(key: string, dflt: T): T => {
try {
const row = uidb.prepare("SELECT value FROM sub_meta WHERE key = ?").get(key) as { value: string } | undefined;
return row ? JSON.parse(row.value) as T : dflt;
} catch { return dflt; }
};
const metaSet = (key: string, v: unknown) =>
uidb.prepare("INSERT INTO sub_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, JSON.stringify(v));
let lastAudit: AuditReport | null = metaGet<AuditReport | null>("audit", null);
let auditSettings: AuditSettings = { ...AUDIT_DEFAULTS, ...metaGet<Partial<AuditSettings>>("audit_settings", {}) };
let cleanedLog: CleanedPeer[] = metaGet<CleanedPeer[]>("audit_cleaned", []);
async function auditNow(id: number): Promise<AuditEntry> {
const entry: AuditEntry = { server_id: id, status: "ok", ghosts_checked: true, ghosts: [], orphans: [], foreign: 0 };
if (!servers.exists(id) || !servers.isOnline(id)) return { ...entry, status: "offline", ghosts_checked: false };
const ctrl = servers.ctrlFor(id);
// Сначала обычное сведение: отчёт — про то, что не снялось само.
await reconcileNow(id);
const [a, p] = [await ctrl("GET", "/api/audit"), await ctrl("GET", "/api/peers")];
if (!hasCtrlOk(p.status)) return { ...entry, status: "error", ghosts_checked: false };
if (hasCtrlOk(a.status)) entry.ghosts = (a.data.ghosts as AuditPeer[]).map(g => ({
pub_key: g.pub_key, online: g.online, lastHandshake: g.lastHandshake,
}));
else if (a.status === 404) entry.ghosts_checked = false;
else return { ...entry, status: "error", ghosts_checked: false };
const keep = new Set((q.keepOf.all(id, id) as { pub: string }[]).map(r => r.pub));
const prefix = panelId + "/";
for (const peer of p.data.peers as { pub_key: string; owner: string; online: boolean; lastHandshake: number }[]) {
if (!peer.owner.startsWith(prefix)) { if (peer.owner.includes("/")) entry.foreign++; continue; }
if (keep.has(peer.pub_key)) continue;
const mid = Number(peer.owner.match(/^[^/]+\/m(\d+)\//)?.[1]);
const m = Number.isSafeInteger(mid) ? q.masterRow.get(mid) as MasterRow | undefined : undefined;
entry.orphans.push({
pub_key: peer.pub_key, owner: peer.owner, online: peer.online, lastHandshake: peer.lastHandshake,
key: m ? (m.revoked_at !== null ? `${m.label} (удалён)` : m.label) : "(нет в ui.db)",
});
}
return entry;
}
// Под той же блокировкой, что и сведение: пир, которого прямо сейчас добавляет проход, не «призрак».
function audit(id: number): Promise<AuditEntry> {
const run = (locks.get(id) ?? Promise.resolve()).then(() => auditNow(id));
locks.set(id, run.then(() => {}, () => {}));
return run.catch((): AuditEntry => ({ server_id: id, status: "error", ghosts_checked: false, ghosts: [], orphans: [], foreign: 0 }));
}
// Снять «мёртвых» этого сервера: только подтверждённых (seen_before), не больше max за проход.
// «Призраки» — через awg-ctrl (он сам перепроверяет ключи), лишние — обычным DELETE.
async function cleanEntry(e: AuditEntry, auto: boolean): Promise<void> {
const ghosts = e.ghosts.filter(p => p.seen_before);
const orphans = e.orphans.filter(p => p.seen_before);
if (ghosts.length + orphans.length === 0 || e.status !== "ok") return;
if (auto && ghosts.length + orphans.length > auditSettings.max) {
e.blocked = ghosts.length + orphans.length;
console.warn(`sub: очистка сервера #${e.server_id} пропущена: ${e.blocked} пиров больше предела ${auditSettings.max}`);
return;
}
const ctrl = servers.ctrlFor(e.server_id);
const done: CleanedPeer[] = [];
const log = (p: AuditPeer, kind: CleanedPeer["kind"]) =>
done.push({ at: now(), server_id: e.server_id, pub_key: p.pub_key, kind, key: p.key, auto });
if (ghosts.length) {
const r = await ctrl("POST", "/api/audit/clean", { pub_keys: ghosts.map(g => g.pub_key) });
if (hasCtrlOk(r.status)) {
const gone = new Set(r.data.removed as string[]);
for (const g of ghosts) if (gone.has(g.pub_key)) log(g, "ghost");
}
}
for (const p of orphans) {
const r = await ctrl("DELETE", `/api/peers/${pubParam(p.pub_key)}`);
if (hasCtrlOk(r.status) || r.status === 404) log(p, "orphan");
else if (gone502(r.status)) break;
}
if (!done.length) return;
const cleaned = new Set(done.map(d => d.pub_key));
e.ghosts = e.ghosts.filter(p => !cleaned.has(p.pub_key));
e.orphans = e.orphans.filter(p => !cleaned.has(p.pub_key));
cleanedLog = [...done, ...cleanedLog].slice(0, 100);
metaSet("audit_cleaned", cleanedLog);
console.warn(`sub: ${auto ? "автоочистка" : "очистка"} сервера #${e.server_id}: снято ${done.length} (${done.map(d => d.pub_key.slice(0, 8)).join(", ")})`);
}
const gone502 = (s: number) => s === 502 || s === 504;
let auditing: Promise<AuditReport> | null = null;
// auto — плановый проход: очищает сам, если включено. Ручной только смотрит и обновляет «видели раньше».
function auditAll(auto = false): Promise<AuditReport> {
auditing ??= (async () => {
const prev = lastAudit;
const report: AuditReport = { at: now(), servers: await Promise.all(servers.ids().map(audit)) };
for (const e of report.servers) {
const before = prev?.servers.find(x => x.server_id === e.server_id);
const was = new Set([...(before?.ghosts ?? []), ...(before?.orphans ?? [])].map(p => p.pub_key));
for (const p of [...e.ghosts, ...e.orphans]) p.seen_before = was.has(p.pub_key);
if (auto && auditSettings.clean) await audit_clean(e);
}
lastAudit = report;
metaSet("audit", report);
for (const e of report.servers) {
if (e.status === "error") console.warn(`sub: аудит сервера #${e.server_id} не удался`);
if (e.ghosts.length || e.orphans.length)
console.warn(`sub: аудит сервера #${e.server_id}: мёртвых пиров ${e.ghosts.length + e.orphans.length} ` +
`(в ядре без записи: ${e.ghosts.length}, лишних у awg-ctrl: ${e.orphans.length}, онлайн: ` +
`${[...e.ghosts, ...e.orphans].filter(x => x.online).length})`);
}
return report;
})().finally(() => { auditing = null; });
return auditing;
}
// Очистка под блокировкой сервера — как и сам аудит.
function audit_clean(e: AuditEntry): Promise<void> {
const run = (locks.get(e.server_id) ?? Promise.resolve()).then(() => cleanEntry(e, true));
locks.set(e.server_id, run.then(() => {}, () => {}));
return run.catch(err => { console.warn(`sub: очистка сервера #${e.server_id} не удалась: ${(err as Error).message}`); });
}
// Ручная очистка подтверждённых «мёртвых» по последнему отчёту (кнопка на вкладке «Аудит»).
async function cleanNow(): Promise<AuditReport | null> {
if (!lastAudit) return null;
for (const e of lastAudit.servers) {
const run = (locks.get(e.server_id) ?? Promise.resolve()).then(() => cleanEntry(e, false));
locks.set(e.server_id, run.then(() => {}, () => {}));
await run.catch(() => {});
}
metaSet("audit", lastAudit);
return lastAudit;
}
const auditState = () => ({ enabled: AUDIT_ON, settings: auditSettings, report: lastAudit, cleaned: cleanedLog });
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
function forgetDevice(dev: DeviceRow): number[] {
const links = q.linksOf.all(dev.id) as LinkRow[];
@@ -577,7 +745,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
// без отдельного запроса списка.
send(req, res, 201, {
device: dev.id, config: cfg,
device: dev.id, config: cfg, key_uuid: master.uuid,
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
});
}));
@@ -594,7 +762,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
send(req, res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
return;
}
send(req, res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
send(req, res, 200, { key_uuid: master.uuid, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
}));
sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
@@ -614,6 +782,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
send(req, res, 200, {
name: master.label,
key_uuid: master.uuid,
device_limit: master.device_limit,
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
@@ -674,6 +843,11 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
});
};
const idOf = (v: string) => { const n = Number(v); return Number.isSafeInteger(n) ? n : -1; }; // -1 — не найдётся
// Мастер-ключ в пути — числовой id или uuid.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const masterIdOf = (v: string) => UUID_RE.test(v)
? (q.masterIdByUuid.get(v.toLowerCase()) as { id: number } | undefined)?.id ?? -1
: idOf(v);
const LABEL = /^[^\r\n<>]{1,40}$/;
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
@@ -707,7 +881,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
function masterOut(m: MasterRow & { devices: number }) {
return {
id: m.id, label: m.label, device_limit: m.device_limit,
id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit,
devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at,
...(m.revoked_at !== null ? { deleting: serverIdsOf(m.id) } : {}),
};
@@ -728,7 +902,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
catch (e) { throw wanted === undefined && scope === null ? new ApiError(400, "Сначала добавьте сервер") : e; }
let id = 0;
uidb.transaction(() => {
id = Number(q.masterInsert.run(label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
id = Number(q.masterInsert.run(crypto.randomUUID(), label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
for (const sid of ids) q.serverAdd.run(id, sid);
})();
return masterOutById(id);
@@ -764,8 +938,19 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
ids = next;
}
q.masterUpdate.run(nl as string, nd as number, m.id);
// Лимит стал меньше, чем устройств: лишние отвязываются, начиная с подключившихся последними, —
// первые, кто занял места, их сохраняют. Как отзыв в панели: пиры снимаются, устройство получит 410.
const unbound: number[] = [];
const devices = q.devicesOf.all(m.id) as DeviceRow[];
if (devices.length > (nd as number)) {
const newest = [...devices].sort((a, b) => b.created_at - a.created_at || b.id - a.id);
for (const d of newest.slice(0, devices.length - (nd as number))) {
affected.push(...forgetDevice(d));
unbound.push(d.id);
}
}
await reconcileAll(affected);
return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids };
return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids, unbound };
}
// Новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
@@ -806,15 +991,17 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
async function masterDevices(m: MasterRow) {
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number; background: boolean }>();
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
const r = await servers.ctrlFor(id)("GET", "/api/peers");
if (!hasCtrlOk(r.status)) return;
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number }[]) {
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0 };
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: string }[]) {
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0, background: false };
stats.set(p.pub_key, {
online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake),
rx: s.rx + p.rx, tx: s.tx + p.tx,
// Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них.
background: s.background || p.priority === "background",
});
}
}));
@@ -825,6 +1012,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0,
priority: s?.background ? "background" : "normal",
servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length,
};
});
@@ -848,13 +1036,42 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
masterKeys.get("/", (_req, res) => {
res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) });
});
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
masterKeys.get("/audit", (_req, res) => { res.json(auditState()); });
masterKeys.post("/audit", aw(async (_req, res) => {
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
await auditAll();
res.json(auditState());
}));
masterKeys.post("/audit/clean", aw(async (_req, res) => {
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
await cleanNow();
res.json(auditState());
}));
masterKeys.put("/audit/settings", aw((req, res) => {
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
const b = (req.body ?? {}) as Partial<AuditSettings>;
const next = { ...auditSettings };
if (b.clean !== undefined) { if (typeof b.clean !== "boolean") fail(400, "clean: true или false"); next.clean = b.clean; }
if (b.every_h !== undefined) {
if (!Number.isInteger(b.every_h) || b.every_h < 1 || b.every_h > 168) fail(400, "Период: от 1 до 168 часов");
next.every_h = b.every_h;
}
if (b.max !== undefined) {
if (!Number.isInteger(b.max) || b.max < 1 || b.max > 1000) fail(400, "Предел: от 1 до 1000");
next.max = b.max;
}
auditSettings = next;
metaSet("audit_settings", next);
res.json(auditState());
}));
masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); }));
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(idOf(req.params.id), null), req.body, null)); }));
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(idOf(req.params.id), null))); }));
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(idOf(req.params.id), null), req)); }));
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(idOf(req.params.id), null)) }); }));
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(masterIdOf(req.params.id), null), req.body, null)); }));
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(masterIdOf(req.params.id), null))); }));
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(masterIdOf(req.params.id), null), req)); }));
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(masterIdOf(req.params.id), null)) }); }));
const devices = Router();
devices.delete("/:id", aw(async (req, res) => { res.json(await deleteDevice(deviceFor(idOf(req.params.id)))); }));
@@ -866,7 +1083,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// область проверялась через их мастер-ключ.
function apiRouter(scopeOf: (req: Request) => number[]) {
const r = Router();
const master = (req: Request) => masterFor(idOf(req.params.id), scopeOf(req));
const master = (req: Request) => masterFor(masterIdOf(req.params.id), scopeOf(req));
const device = (req: Request) => deviceFor(idOf(req.params.device), master(req));
r.get("/", aw((req, res) => { res.json({ keys: listMasters(scopeOf(req)) }); }));
@@ -927,6 +1144,15 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
}, SYNC_EVERY).unref();
if (AUDIT_ON) {
// Период читается из настроек каждый раз: смена в панели действует без рестарта.
const next = (ms: number) => setTimeout(() => {
auditAll(true).catch(e => console.warn(`sub: аудит не удался: ${(e as Error).message}`))
.finally(() => next(auditSettings.every_h * AUDIT_HOUR));
}, ms).unref();
next(AUDIT_FIRST);
}
if (!signKey) return;
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.3.3",
"version": "0.4.5",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+7 -1
View File
@@ -9,6 +9,7 @@ import * as readline from "readline";
import * as crypto from "crypto";
import * as http from "http";
import path from "path";
import { monitorCmd } from "./monitor";
const ROOT = path.resolve(__dirname, "../..");
const LOGS = path.join(ROOT, "logs");
@@ -107,6 +108,10 @@ const SERVICES = {
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
// core без собственного VPN: awg-ctrl рядом не нужен.
NODE_PORT: process.env.NODE_PORT ?? "",
// Часовой аудит «мёртвых» пиров на всех серверах — только у core (у ноды со своей панелью выключен).
AUDIT: process.env.CORE_HOST ? "off" : (process.env.AUDIT ?? "on"),
// Пер-пировые серии в /metrics: только по явному согласию оператора (cli.env).
METRICS_PEERS: process.env.METRICS_PEERS ?? "off",
LOCAL_NODE: process.env.LOCAL_NODE ?? "on",
NODE_TLS_KEY_FILE: process.env.NODE_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/node_tls.key",
NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
@@ -640,7 +645,7 @@ async function main() {
return;
}
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]>";
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>";
const [,,, sub, arg] = process.argv;
@@ -657,6 +662,7 @@ async function main() {
case "join": await joinCore(sub, arg); break;
case "unjoin": await unjoinCore(); break;
case "peers": await peersCmd(sub, arg); break;
case "monitor": await monitorCmd(sub); break;
default: console.log(USAGE); process.exit(1);
}
}
+170
View File
@@ -0,0 +1,170 @@
// 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.
//
// `awg-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор».
// Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не ставит, она только просит
// (через awg-ctrl) или показывает эту команду. Всё слушает 127.0.0.1: наружу Prometheus не торчит,
// а панель читает его по HTTP на той же машине.
import { spawnSync } from "child_process";
import { readFileSync, writeFileSync, existsSync, copyFileSync, unlinkSync } from "fs";
import * as crypto from "crypto";
import * as http from "http";
const AWG_DIR = "/etc/amnezia/amneziawg";
const PROM_DIR = "/etc/prometheus";
const CONF_FILE = `${PROM_DIR}/prometheus.yml`;
const TOKEN_FILE = `${PROM_DIR}/forgetting.token`;
// Хэш локального токена для awg-ui: читает его при проверке /metrics (сам токен знает только Prometheus).
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 = "31d"; // с запасом на вид «30 дней» во вкладке «Монитор»
const log = (s: string) => console.log(` ${s}`);
function fail(s: string): never { console.error(`\n ✗ ${s}\n`); process.exit(1); }
function sh(cmd: string, args: string[], opts: { quiet?: boolean } = {}) {
return spawnSync(cmd, args, {
encoding: "utf8",
stdio: opts.quiet ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
env: { ...process.env, DEBIAN_FRONTEND: "noninteractive" },
});
}
function osFamily(): string {
try {
const t = readFileSync("/etc/os-release", "utf8");
const id = t.match(/^ID=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
const like = t.match(/^ID_LIKE=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
return `${id} ${like}`;
} catch { return ""; }
}
function ready(timeoutMs = 1500): Promise<boolean> {
return new Promise(resolve => {
const [host, port] = LISTEN.split(":");
const req = http.get({ host, port: Number(port), path: "/-/ready", timeout: timeoutMs }, res => {
res.resume(); resolve(res.statusCode === 200);
});
req.on("timeout", () => { req.destroy(); resolve(false); });
req.on("error", () => resolve(false));
});
}
const installed = () => sh("dpkg", ["-s", "prometheus"], { quiet: true }).status === 0;
const active = () => sh("systemctl", ["is-active", "--quiet", "prometheus"], { quiet: true }).status === 0;
const managed = () => existsSync(CONF_FILE) && readFileSync(CONF_FILE, "utf8").includes(MARKER);
// Новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран.
function authBlock(): string {
const v = sh("prometheus", ["--version"], { quiet: true });
const m = `${v.stdout ?? ""}${v.stderr ?? ""}`.match(/version (\d+)\.(\d+)/);
const [maj, min] = m ? [Number(m[1]), Number(m[2])] : [2, 0];
return maj > 2 || (maj === 2 && min >= 26)
? ` authorization:\n credentials_file: ${TOKEN_FILE}\n`
: ` bearer_token_file: ${TOKEN_FILE}\n`;
}
function writeConfig(uiPort: string) {
if (existsSync(CONF_FILE)) copyFileSync(CONF_FILE, `${CONF_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
writeFileSync(CONF_FILE, `${MARKER}
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}']
`, { mode: 0o644 });
}
// Токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш.
function writeToken() {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
writeFileSync(TOKEN_FILE, token, { mode: 0o640 });
sh("chown", ["root:prometheus", TOKEN_FILE], { quiet: true });
writeFileSync(MONITOR_HASH_FILE, crypto.createHash("sha256").update(token).digest("hex") + "\n", { mode: 0o644 });
}
function setArgs() {
const line = `ARGS="--web.listen-address=${LISTEN} --storage.tsdb.retention.time=${RETENTION}"`;
let text = existsSync(DEFAULTS_FILE) ? readFileSync(DEFAULTS_FILE, "utf8") : "";
if (text) copyFileSync(DEFAULTS_FILE, `${DEFAULTS_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
text = /^ARGS=/m.test(text) ? text.replace(/^ARGS=.*$/m, line) : `${text.replace(/\n?$/, "\n")}${line}\n`;
writeFileSync(DEFAULTS_FILE, text, { mode: 0o644 });
}
async function install() {
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor install");
if (!/debian|ubuntu/i.test(osFamily())) fail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).");
const uiPort = process.env.UI_PORT;
if (!uiPort) fail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.");
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
if (installed() && existsSync(CONF_FILE) && !managed()) {
fail(`Prometheus уже установлен и настроен не нами (${CONF_FILE}). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.`);
}
log("1/4 установка пакета prometheus");
if (!installed()) {
if (sh("apt-get", ["update", "-qq"]).status !== 0) fail("apt-get update завершился ошибкой");
if (sh("apt-get", ["install", "-y", "--no-install-recommends", "prometheus"]).status !== 0) fail("apt-get install prometheus завершился ошибкой");
} else log("уже установлен");
log("2/4 токен и конфиг");
writeToken();
writeConfig(uiPort);
log(`3/4 слушаем только ${LISTEN}, хранение ${RETENTION}`);
setArgs();
log("4/4 запуск");
sh("systemctl", ["enable", "prometheus"], { quiet: true });
if (sh("systemctl", ["restart", "prometheus"]).status !== 0) fail("prometheus не запустился: journalctl -u prometheus");
for (let i = 0; i < 30; i++) {
if (await ready()) { console.log(`\n ✓ Prometheus работает на ${LISTEN}, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n`); return; }
await new Promise(r => setTimeout(r, 1000));
}
fail("Prometheus не ответил за 30 с: journalctl -u prometheus");
}
async function status() {
const ok = await ready();
console.log(` пакет: ${installed() ? "установлен" : "нет"}`);
console.log(` сервис: ${active() ? "работает" : "остановлен"}`);
console.log(` наш конфиг: ${managed() ? "да" : "нет"}`);
console.log(` готов: ${ok ? `да, ${LISTEN}` : "нет"}`);
}
function remove() {
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor remove");
// Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили.
if (installed() && existsSync(CONF_FILE) && !managed()) {
fail(`Prometheus настроен не нами (${CONF_FILE}), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus`);
}
log("1/3 остановка сервиса");
sh("systemctl", ["disable", "--now", "prometheus"], { quiet: true });
log("2/3 токен");
for (const f of [TOKEN_FILE, MONITOR_HASH_FILE]) { try { unlinkSync(f); } catch { /* уже нет */ } }
log("3/3 удаление пакета и накопленных данных");
if (installed() && sh("apt-get", ["purge", "-y", "prometheus"]).status !== 0) fail("apt-get purge prometheus завершился ошибкой");
console.log("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n");
}
export async function monitorCmd(sub?: string) {
switch (sub) {
case "install": await install(); break;
case "status": await status(); break;
case "remove": remove(); break;
default: console.log("Usage: awg-ctrl monitor <install|status|remove>"); process.exit(1);
}
}
+74
View File
@@ -0,0 +1,74 @@
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent / relay и команды оператора.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/maeneko/forgetting/internal/agent"
"github.com/maeneko/forgetting/internal/cli"
"github.com/maeneko/forgetting/internal/ctrl"
"github.com/maeneko/forgetting/internal/relay"
"github.com/maeneko/forgetting/internal/ui"
)
// version — из сборки релиза (-ldflags "-X main.version=…").
var version = "dev"
func main() {
// Без аргументов или с командой оператора — CLI (меню, start/stop/…), как прежний awg-ctrl.
if len(os.Args) < 2 {
cli.Main(nil)
return
}
switch os.Args[1] {
case "version":
fmt.Println(version)
case "ctrl":
s, err := ctrl.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := s.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "ui":
a, err := ui.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := a.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "agent":
c, err := agent.ConfigFromEnv()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
a, err := agent.New(c)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
a.Run(ctx)
case "relay":
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := relay.Main(ctx); err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
cli.Main(os.Args[1:])
}
}
+17
View File
@@ -0,0 +1,17 @@
module github.com/maeneko/forgetting
go 1.26.0
require (
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+80 -90
View File
@@ -21,7 +21,7 @@ fail() { trap - ERR; echo -e "\n ${RED}✗${NC} $*" >&2; exit 1; }
info() { echo -e " ${DIM}→ $*${NC}"; }
skip() { echo -e " ${DIM}– $*${NC}"; }
rule() { echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"; }
# Заголовок шага: step "1/7 AmneziaWG" → «▍ AmneziaWG 1 из 7»; без номера — просто заголовок.
# Заголовок шага: step "1/6 AmneziaWG" → «▍ AmneziaWG 1 из 6»; без номера — просто заголовок.
step() {
if [[ "$1" =~ ^([0-9]+)/([0-9]+)\ +(.*)$ ]]; then
echo -e "\n${ACC}▍${NC}${BLD} ${BASH_REMATCH[3]}${NC} ${DIM}${BASH_REMATCH[1]} из ${BASH_REMATCH[2]}${NC}"
@@ -84,7 +84,7 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
# Лог установки — с первой строки: всё, что видно на экране (шапка, проверки, вопросы с
# ответами, вывод apt/DKMS/npm), плюс то, что установщик раньше глушил. В файле — без цветов
# ответами, вывод apt/DKMS), плюс то, что установщик раньше глушил. В файле — без цветов
# и без перерисовок строк (ESC-последовательности и всё до последнего \r вырезаются).
# Права 600: в итоге печатается пароль панели. LOGFILE — глобал, на него ссылается ERR-трап.
LOGFILE="/var/log/awg-install-$(date +%Y%m%d-%H%M%S).log"
@@ -100,23 +100,29 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места.
VERSION="0.3.3"
VERSION="0.4.5"
CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
# ничего не зашито. У Gitea и GitHub путь релиза одинаковый
# (<repo>/releases/download/v<VERSION>/<file>), поэтому одна схема покрывает оба.
# REPO_BASE — основной источник (своё зеркало);
# REPO_FALLBACK — запасной, если в основном релиза ещё нет;
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
# переопределяется окружением. Путь релиза — <repo>/releases/download/v<VERSION>/<file>
# (такой же у Gitea, так что подойдёт и своё зеркало).
# REPO_BASE — основной источник;
# REPO_FALLBACK — запасной, если в основном релиза нет (по умолчанию не задан);
# ARCHIVE_URL — полный URL архива, перекрывает оба варианта;
# BRAND — имя продукта в баннерах, systemd-юните и панели; по
# умолчанию берётся из .env в архиве, здесь только запасное
# значение для баннера самого установщика.
BRAND_OVERRIDE="${BRAND:-}"
BRAND="${BRAND:-Forgetting}"
REPO_BASE="${REPO_BASE:-https://git.ma7neko.ru/maeneko/forgetting}"
REPO_FALLBACK="${REPO_FALLBACK:-https://github.com/maeneko/forgetting}"
ARCHIVE_NAME="awgcontrol-${VERSION}.tar.gz"
REPO_BASE="${REPO_BASE:-https://github.com/maeneko/forgetting}"
REPO_FALLBACK="${REPO_FALLBACK:-$REPO_BASE}"
# Архив — под архитектуру: в нём готовый бинарник awg-control (Go, статический).
case "$(uname -m)" in
x86_64|amd64) GOARCH="amd64" ;;
aarch64|arm64) GOARCH="arm64" ;;
*) GOARCH="" ;;
esac
ARCHIVE_NAME="awgcontrol-${VERSION}-linux-${GOARCH:-unknown}.tar.gz"
ARCHIVE_URL="${ARCHIVE_URL:-}"
url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; }
PROJECT="/opt/awg-control"
@@ -147,10 +153,9 @@ AWG_PORT="47619"
SUBNET="10.9"
MTU="1376"
# Запуск CLI: tsx напрямую из node_modules (не npx) — чтобы PID был реальным
# (та же причина, что у обёртки /usr/local/bin/awg-ctrl). Нужно обоим режимам.
TSX="$PROJECT/cli/node_modules/.bin/tsx"
CLI="$PROJECT/cli/src/index.ts"
# Единый бинарник: сервисы (ctrl/ui/agent) и CLI (start/stop/status/…). Команда awg-ctrl —
# ссылка на него.
BIN="$PROJECT/awg-control"
echo
echo -e " ${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}"
@@ -270,8 +275,14 @@ echo -e " ${DIM}${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)} ·
# Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист
# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим
# с кодом 0 (чистый выход, без вида «упало с ошибкой»).
OS_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1
OS_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY=""
OS_OK=1; ARCH_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1
OS_WHY=""; ARCH_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY=""
# Архитектура: релиз собирается под amd64 и arm64.
if [[ -z "$GOARCH" ]]; then
ARCH_OK=0
ARCH_WHY="архитектура '$(uname -m)' не поддерживается — сборки есть для x86_64 и aarch64"
fi
# 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian.
if [[ -z "$OS_FAMILY" ]]; then
@@ -304,7 +315,7 @@ if [[ "$ROLE" != "core" ]]; then
fi
fi
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
# 3) Доступ в интернет: нужен для архива и пакетов AWG. Без -f: любой
# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS.
# Проверяем хосты, с которых реально качаем: хватает любого из двух —
# download_extract() умеет откатываться на запасной.
@@ -321,16 +332,18 @@ done
# Чеклист: ✓ — пройдено, ✗ — нет.
mark() { if [[ "$1" == 1 ]]; then echo -e " ${GRN}✓${NC} $2"; else echo -e " ${RED}✗${NC} $2"; fi; }
mark "$OS_OK" "Дистрибутив"
mark "$ARCH_OK" "Архитектура"
if [[ "$ROLE" != "core" ]]; then
mark "$VIRT_OK" "Виртуализация"
mark "$HDR_OK" "Заголовки ядра"
fi
mark "$NET_OK" "Доступ в интернет"
if [[ "$OS_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then
if [[ "$OS_OK" == 0 || "$ARCH_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then
echo
warn "Установка невозможна — не пройдены проверки:"
[[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY"
[[ "$ARCH_OK" == 0 ]] && echo -e " ${RED}•${NC} $ARCH_WHY"
[[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY"
[[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY"
[[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY"
@@ -388,14 +401,19 @@ NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
# Сначала корректно через CLI (по PID-файлам), затем добиваем всё, что ещё
# держит файлы проекта: осиротевшие процессы, ручной запуск или stale PID,
# которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе node
# продолжит работать с уже удалёнными файлами и порт/интерфейс останутся занятыми.
# которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе сервисы
# продолжат работать с уже удалёнными файлами и порт/интерфейс останутся занятыми.
kill_related() {
local _tsx="$PROJECT/cli/node_modules/.bin/tsx"
local _cli="$PROJECT/cli/src/index.ts"
[[ -x "$_tsx" && -f "$_cli" ]] && "$_tsx" "$_cli" stop all 2>/dev/null || true
if [[ -x "$BIN" ]]; then
"$BIN" stop all 2>/dev/null || true
else
# Установка до Go-версии: её CLI на tsx.
local _tsx="$PROJECT/cli/node_modules/.bin/tsx"
local _cli="$PROJECT/cli/src/index.ts"
[[ -x "$_tsx" && -f "$_cli" ]] && "$_tsx" "$_cli" stop all 2>/dev/null || true
fi
# cmdline всех сервисов (tsx awg-ctrl/awg-ui/cli) содержит путь проекта.
# cmdline всех сервисов (awg-control <роль>, у старых — tsx …) содержит путь проекта.
if command -v pkill &>/dev/null; then
pkill -TERM -f "$PROJECT/" 2>/dev/null || true
sleep 1
@@ -420,7 +438,7 @@ download_extract() {
# AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах (или
# встать без сети). Архив должен быть той же формы, что делает CI:
# awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/ + .env, без node_modules.
# awg-control (бинарник под эту архитектуру) + awg-ui/dist/ + .env.
if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then
[[ -f "$AWG_LOCAL_TARBALL" ]] \
|| fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL"
@@ -451,42 +469,40 @@ $(printf ' %s\n' "${urls[@]}")
fi
mkdir -p "$PROJECT"
remove_legacy
run tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \
|| fail "Не удалось распаковать архив"
[[ "$keep_tarball" == 1 ]] || rm -f "$tmp"
ok "Распакован → $PROJECT"
local f
for f in \
"$PROJECT/awg-ctrl/index.ts" \
"$PROJECT/awg-ctrl/package.json" \
"$PROJECT/awg-ui/public/index.html" \
"$PROJECT/awg-ui/server.ts" \
"$PROJECT/awg-ui/package.json" \
"$PROJECT/awg-agent/index.ts" \
"$PROJECT/awg-agent/package.json" \
"$PROJECT/cli/src/index.ts" \
"$PROJECT/cli/package.json"
do
for f in "$BIN" "$PROJECT/awg-ui/dist/index.html" "$PROJECT/.env"; do
[[ -f "$f" ]] || fail "Файл не найден после распаковки: $f"
done
# Рабочие каталоги сервисов: в них живут panel.id, логи awg-ctrl, cli.env.
mkdir -p "$PROJECT/awg-ctrl" "$PROJECT/awg-ui" "$PROJECT/awg-agent" "$PROJECT/cli" "$PROJECT/logs"
chmod 755 "$BIN"
"$BIN" version >/dev/null 2>&1 || fail "Бинарник $BIN не запускается (архив не под эту архитектуру?)"
ok "Архив проверен"
}
# npm install во всех сервисах.
npm_install_all() {
local SVC
for SVC in awg-ctrl awg-ui awg-agent cli; do
if [[ -f "$PROJECT/$SVC/package.json" ]]; then
info "npm install · $SVC"
(cd "$PROJECT/$SVC" && run npm install) \
|| fail "npm install в $SVC завершился ошибкой"
else
warn "$PROJECT/$SVC/package.json не найден — пропуск"
fi
# remove_legacy — при обновлении убрать код прежней версии: TS-исходники, node_modules и старую
# сборку фронтенда. Данные остаются: cli/cli.env, awg-ui/panel.id, логи (базы и ключи — в /etc).
remove_legacy() {
local d
for d in awg-ctrl awg-ui awg-agent cli; do
[[ -d "$PROJECT/$d" ]] || continue
rm -rf "$PROJECT/$d/node_modules" "$PROJECT/$d/src" "$PROJECT/$d/public" "$PROJECT/$d/dist"
rm -f "$PROJECT/$d"/*.ts "$PROJECT/$d"/package*.json "$PROJECT/$d"/tsconfig*.json "$PROJECT/$d/index.html"
done
}
# Команда awg-ctrl — ссылка на бинарник: CLI находит корень установки по её цели.
install_command() {
ln -sfn "$BIN" /usr/local/bin/awg-ctrl
ok "awg-ctrl → $BIN"
}
# systemd-юнит: автозапуск awg-control на загрузке. Модель CLI — детач-процессы
# с PID-файлами (а не один долгоживущий процесс), поэтому Type=oneshot +
# RemainAfterExit: systemd держит юнит «active», процессами рулит CLI через
@@ -494,12 +510,8 @@ npm_install_all() {
# вручную не выполняется — поднимаем интерфейс в ExecStartPre (если ещё не поднят).
SERVICE_UNIT="/etc/systemd/system/awg-control.service"
setup_service() {
# node может стоять вне дефолтного PATH systemd (nvm и т.п.), а tsx
# запускается через shebang `#!/usr/bin/env node` — иначе ExecStart падает с
# кодом 127 «node not found». Прописываем реальный каталог node в PATH юнита;
# sbin тоже включаем (awg-quick зовёт iptables/ip/sysctl).
local node_dir pre="" what
node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")
# sbin в PATH юнита: awg-quick зовёт iptables/ip/sysctl.
local pre="" what
# Панели без VPN (core) интерфейс не нужен — ExecStartPre только там, где есть awg-ctrl.
[[ "$ROLE" != "core" ]] && pre="ExecStartPre=/bin/sh -c 'awg show ${IFACE} >/dev/null 2>&1 || awg-quick up ${IFACE}'"
case "$ROLE" in
@@ -516,7 +528,7 @@ Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=PATH=${node_dir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
${pre}
ExecStart=/usr/local/bin/awg-ctrl start all
ExecStop=/usr/local/bin/awg-ctrl stop all
@@ -539,10 +551,10 @@ start_and_status() {
|| fail "systemctl restart awg-control завершился ошибкой"
else
warn "systemd не найден — запускаю напрямую через CLI (без автозапуска)"
run "$TSX" "$CLI" start all
run "$BIN" start all
fi
sleep 2
"$TSX" "$CLI" status
"$BIN" status
}
if [[ "$INSTALL_MODE" == "update" ]]; then
@@ -552,7 +564,7 @@ if [[ "$INSTALL_MODE" == "update" ]]; then
kill_related
download_extract
npm_install_all
install_command
start_and_status
rule
@@ -690,8 +702,9 @@ parse_join() {
CORE_PIN=$(sed -nE 's/.*"pin":"([A-Za-z0-9_-]{43})".*/\1/p' <<< "$json")
NODE_ID=$(sed -nE 's/.*"n":([0-9]+)[,}].*/\1/p' <<< "$json")
JOIN_SECRET=$(sed -nE 's/.*"s":"([A-Za-z0-9_-]{22})".*/\1/p' <<< "$json")
[[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" ]] \
&& (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID > 0 ))
# NODE_ID 0 — сервер, с которого переехала панель (awg-ctrl migrate import).
[[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" && -n "$NODE_ID" ]] \
&& (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID >= 0 ))
}
# SHA-256 от SPKI сертификата панели, base64url без «=» — в том же виде, что pin в строке.
@@ -792,7 +805,7 @@ ask YN "Продолжить? [Y/n]: "
# Совместимость (виртуализация, заголовки ядра, интернет) проверена выше в
# секции «Проверка совместимости»; $KERNEL задан там же.
step "1/7 AmneziaWG"
step "1/6 AmneziaWG"
if [[ "$ROLE" == "core" ]]; then
skip "пропущено — у панели без VPN AmneziaWG не нужен"
@@ -1022,24 +1035,11 @@ else
fi
fi
step "2/7 Node.js"
if command -v node &>/dev/null; then
ok "Node.js уже установлен: $(node --version)"
else
info "установка Node.js 20.x (nodesource)"
run bash -c 'set -o pipefail; curl -fsSL https://deb.nodesource.com/setup_20.x | bash -' \
|| fail "Не удалось подключить репозиторий Node.js (deb.nodesource.com)"
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
run apt-get install -y nodejs || fail "Не удалось установить Node.js"
ok "Node.js установлен: $(node --version)"
fi
step "3/7 Файлы проекта"
step "2/6 Файлы проекта"
download_extract
step "4/7 Ключи и конфиг AWG"
step "3/6 Ключи и конфиг AWG"
mkdir -p "$AWG_DIR" "$AMNEZIA_DIR"
@@ -1196,7 +1196,7 @@ CONF
fi
fi
step "5/7 Запуск AWG"
step "4/6 Запуск AWG"
if [[ "$ROLE" == "core" ]]; then
skip "пропущено — у панели без VPN интерфейса нет"
@@ -1307,21 +1307,11 @@ else
info "UFW не активен, правила фаервола не трогаем"
fi
step "6/7 npm install"
step "5/6 Команда awg-ctrl"
npm_install_all
install_command
# Глобальный бинарник — используем tsx напрямую из node_modules (не npx)
# чтобы child.pid в cli был реальным PID процесса
cat > /usr/local/bin/awg-ctrl << 'WRAPPER'
#!/usr/bin/env bash
exec /opt/awg-control/cli/node_modules/.bin/tsx \
/opt/awg-control/cli/src/index.ts "$@"
WRAPPER
chmod +x /usr/local/bin/awg-ctrl
ok "awg-ctrl → /usr/local/bin/awg-ctrl"
step "7/7 Запуск сервисов"
step "6/6 Запуск сервисов"
# cli.env по роли. Роль CLI узнаёт отсюда же: CORE_HOST → нода (awg-ctrl + awg-agent),
# LOCAL_NODE=off → панель без VPN (только awg-ui), иначе — awg-ctrl + awg-ui.
+530
View File
@@ -0,0 +1,530 @@
// Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее
// WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет
// присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет,
// awg-ctrl остаётся закрытым от сети. Протокол и формат join-строки — docs/node-protocol.md.
package agent
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64 = base64.RawURLEncoding
const (
handshakeTimeout = 10 * time.Second
deadAfter = 60 * time.Second // core пингует чаще; тишина дольше — соединение мёртвое
maxPayload = 4 * 1024 * 1024
ctrlTimeout = 110 * time.Second
maxBackoff = 60 * time.Second
closeAuthFailed = 4003
)
type Config struct {
CoreHost string
CorePort int
CoreEndpoint string // «host:port», который core сообщил сам (после переезда панели); пробуется первым
CorePin string // SHA-256 SPKI сертификата core, base64url
NodeID int
Ctrl string // http://127.0.0.1:PORT
NodeKeyFile string
InternalKey string
CLIEnvFile string
JoinSecret string // одноразовый, живёт до первого успешного подключения
Version string
Logf, Errorf func(format string, a ...any)
}
// envFromFile — значение KEY=… из .env (как в Node-версии: первая строка, обрезанная).
func envFromFile(file, key string) string {
raw, err := os.ReadFile(file)
if err != nil {
return ""
}
m := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(.*)$`).FindSubmatch(raw)
if m == nil {
return ""
}
return strings.TrimSpace(string(m[1]))
}
// ConfigFromEnv собирает конфиг так же, как Node-агент. Каталог агента — рабочий каталог
// процесса (CLI запускает сервис с cwd = <root>/awg-agent): там node.key по умолчанию, а
// .env проекта — уровнем выше.
func ConfigFromEnv() (Config, error) {
cwd, _ := os.Getwd()
atoi := func(k string) int { n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(k))); return n }
// Нода №0 — сервер, с которого переехала панель: 0 здесь — значение, а не «не задано».
nodeID, idErr := strconv.Atoi(strings.TrimSpace(os.Getenv("NODE_ID")))
or := func(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
c := Config{
CoreHost: os.Getenv("CORE_HOST"),
CorePort: atoi("CORE_PORT"),
CorePin: os.Getenv("CORE_PIN"),
CoreEndpoint: os.Getenv("CORE_ENDPOINT"),
NodeID: nodeID,
Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"),
NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")),
InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"),
CLIEnvFile: os.Getenv("CLI_ENV_FILE"),
JoinSecret: os.Getenv("JOIN_SECRET"),
Version: os.Getenv("VERSION"),
}
if c.Version == "" {
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION")
}
if c.CoreHost == "" || c.CorePort == 0 || c.CorePin == "" || idErr != nil || c.NodeID < 0 {
return c, errors.New("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`")
}
return c, nil
}
type Agent struct {
c Config
nodeKey ed25519.PrivateKey
nodePubRaw string
internalKey ed25519.PrivateKey // nil — запросы к awg-ctrl уйдут без токена и получат 401
mu sync.Mutex
joinSecret string
addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT)
cur int
http *http.Client
}
// loadNodeKey — личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
func loadNodeKey(file string, logf func(string, ...any)) (ed25519.PrivateKey, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
if err := os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
return nil, err
}
logf("agent: создан ключ ноды %s", file)
}
return iauth.LoadPrivate(file)
}
func New(c Config) (*Agent, error) {
if c.Logf == nil {
c.Logf = func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
}
if c.Errorf == nil {
c.Errorf = func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
}
key, err := loadNodeKey(c.NodeKeyFile, c.Logf)
if err != nil {
return nil, fmt.Errorf("agent: ключ ноды %s: %w", c.NodeKeyFile, err)
}
a := &Agent{
c: c, nodeKey: key, joinSecret: c.JoinSecret,
nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)),
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout},
}
a.setAddrs(c.CoreEndpoint)
if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil {
a.internalKey = ik
} else {
c.Errorf("agent: приватный ключ внутренней авторизации не найден: %s", c.InternalKey)
}
return a, nil
}
// authMsg дублируется в awg-ui/nodes.ts.
func authMsg(nonce string, nodeID int) []byte {
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID))
}
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
var rePath = regexp.MustCompile(`^/[\x21-\x7e]*$`)
// pathAllowed — агент не открытый прокси: только API самого awg-ctrl. Строже Node-версии
// там, где та полагалась на нормализацию WHATWG URL: обратный слэш и «//» в начале
// (смена хоста) отвергаются сразу, «.»/«..» — и в %2e-записи.
func pathAllowed(p any) (string, bool) {
s, ok := p.(string)
if !ok || len(s) > 2048 || !rePath.MatchString(s) || strings.HasPrefix(s, "//") || strings.Contains(s, `\`) {
return "", false
}
pathname := s
if i := strings.IndexAny(pathname, "?#"); i >= 0 {
pathname = pathname[:i]
}
for _, seg := range strings.Split(pathname, "/") {
switch strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(seg, "%2e", "."), "%2E", ".")) {
case ".", "..":
return "", false
}
}
if pathname == "/health" || strings.HasPrefix(pathname, "/api/") || strings.HasPrefix(pathname, "/awg/") {
return s, true
}
return "", false
}
type reply struct {
status int
data any
}
func errReply(status int, msg string) reply {
return reply{status, jsjson.Object{{Key: "error", Val: msg}}}
}
// execute выполняет запрос core на локальном awg-ctrl. Ответ возвращается как его разобрал
// бы axios: JSON — значением, иначе строкой (пустое тело — "").
func (a *Agent) execute(m jsjson.Object) reply {
method, _ := m.Get2("method").(string)
method = strings.ToUpper(method)
p, ok := pathAllowed(m.Get2("path"))
if !methods[method] || !ok {
return errReply(400, "Запрос не разрешён агентом")
}
var body io.Reader
if v, has := m.Get("body"); has && v != nil {
raw, err := jsjson.Marshal(v)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
body = bytes.NewReader(raw)
}
u, err := url.Parse(a.c.Ctrl + p)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
u.Fragment = ""
req, err := http.NewRequest(method, u.String(), body)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
req.Header.Set("Content-Type", "application/json")
if a.internalKey != nil {
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now()))
}
resp, err := a.http.Do(req)
if err != nil {
return errReply(502, "awg-ctrl недоступен")
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errReply(502, "awg-ctrl недоступен")
}
if len(raw) == 0 {
return reply{resp.StatusCode, ""}
}
if v, err := jsjson.Decode(raw); err == nil {
return reply{resp.StatusCode, v}
}
return reply{resp.StatusCode, string(raw)}
}
// ── Соединение с core ──────────────────────────────────────────────────────
func spkiPin(rawCert []byte) (string, error) {
cert, err := x509.ParseCertificate(rawCert)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64.EncodeToString(sum[:]), nil
}
var errPin = errors.New("сертификат core не совпал с pin — соединение отклонено")
// tlsConfig — сертификат core самоподписанный: цепочку не проверяем, доверие даёт pin.
// Проверка внутри TLS-рукопожатия: секрет и подпись уходят только тому, чей сертификат
// совпал с pin из join-строки.
func (a *Agent) tlsConfig() *tls.Config {
return &tls.Config{
InsecureSkipVerify: true,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errPin
}
got, err := spkiPin(rawCerts[0])
if err != nil || got != a.c.CorePin {
return errPin
}
return nil
},
}
}
// forgetJoinSecret — после первого успешного входа секрет больше не нужен, убираем с диска.
func (a *Agent) forgetJoinSecret() {
a.mu.Lock()
a.joinSecret = ""
a.mu.Unlock()
os.Unsetenv("JOIN_SECRET")
if a.c.CLIEnvFile == "" {
return
}
raw, err := os.ReadFile(a.c.CLIEnvFile)
if err != nil {
return // не критично: секрет одноразовый и с истекающим сроком
}
var kept []string
for _, l := range strings.Split(string(raw), "\n") {
if !strings.HasPrefix(l, "JOIN_SECRET=") {
kept = append(kept, l)
}
}
os.WriteFile(a.c.CLIEnvFile, []byte(strings.Join(kept, "\n")), 0o600)
}
// setAddrs — сначала адрес, который сообщил core, потом адрес из join: как endpoints у клиента
// sen://. Чужой сервер по нему не подменить — доверие даёт pin, а не адрес.
func (a *Agent) setAddrs(learned string) {
a.mu.Lock()
defer a.mu.Unlock()
a.addrs = nil
for _, s := range []string{learned, hostPort(a.c.CoreHost, a.c.CorePort)} {
if s != "" && (len(a.addrs) == 0 || a.addrs[0] != s) {
a.addrs = append(a.addrs, s)
}
}
a.cur = 0
}
func (a *Agent) addr() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.addrs[a.cur%len(a.addrs)]
}
// next — не дозвонились (или core не принял): следующий адрес.
func (a *Agent) next() {
a.mu.Lock()
a.cur = (a.cur + 1) % len(a.addrs)
a.mu.Unlock()
}
// learnCore — core сообщил свой адрес (панель переехала, старый сервер скоро выключат):
// запоминаем в cli.env, чтобы после перезапуска звонить сразу туда. false — не запомнили.
func (a *Agent) learnCore(addr string) bool {
host, port, err := net.SplitHostPort(addr)
if p, perr := strconv.Atoi(port); err != nil || perr != nil || p <= 0 || p > 65535 || !reCoreHost.MatchString(host) {
return false
}
addr = hostPort(host, mustAtoi(port))
if addr == a.c.CoreEndpoint {
return true
}
if a.c.CLIEnvFile == "" {
return false
}
if err := envfile.Set(a.c.CLIEnvFile, [][2]string{{"CORE_ENDPOINT", addr}}); err != nil {
a.c.Errorf("agent: адрес core не сохранён: %v", err)
return false
}
a.c.CoreEndpoint = addr
a.mu.Lock()
cur := a.addrs[a.cur%len(a.addrs)]
a.mu.Unlock()
a.setAddrs(addr)
a.mu.Lock()
for i, s := range a.addrs { // текущее соединение не рвём: адрес пригодится при следующем
if s == cur {
a.cur = i
}
}
a.mu.Unlock()
a.c.Logf("agent: core теперь доступен и по %s", addr)
return true
}
var reCoreHost = regexp.MustCompile(`^[A-Za-z0-9.:-]{1,253}$`)
func mustAtoi(s string) int { n, _ := strconv.Atoi(s); return n }
func (a *Agent) secret() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.joinSecret
}
// connectOnce держит одно соединение до его закрытия. authFailed — core закрыл с 4003.
func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var dead *time.Timer
var deadMu sync.Mutex
armDead := func() {
deadMu.Lock()
defer deadMu.Unlock()
if dead != nil {
dead.Stop()
}
dead = time.AfterFunc(deadAfter, cancel)
}
defer func() {
deadMu.Lock()
if dead != nil {
dead.Stop()
}
deadMu.Unlock()
}()
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout)
ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{
HTTPClient: &http.Client{Transport: tr},
CompressionMode: websocket.CompressionDisabled,
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
})
dcancel()
if err != nil {
if errors.Is(err, errPin) {
a.c.Errorf("agent: %v", errPin)
} else {
a.c.Errorf("agent: %v", err)
}
return false
}
defer ws.CloseNow()
ws.SetReadLimit(maxPayload)
send := func(v any) {
if b, err := jsjson.Marshal(v); err == nil {
ws.Write(ctx, websocket.MessageText, b)
}
}
ready := false
for {
_, raw, err := ws.Read(ctx)
if err != nil {
code := websocket.CloseStatus(err)
if !ready && code == -1 && ctx.Err() == nil {
a.c.Errorf("agent: %v", err)
}
if code == closeAuthFailed {
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
}
return code == closeAuthFailed
}
v, err := jsjson.Decode(raw)
if err != nil {
return false
}
m, _ := v.(jsjson.Object)
switch t, _ := m.Get2("t").(string); {
case t == "hello":
nonce, ok := m.Get2("nonce").(string)
if !ok {
continue
}
auth := jsjson.Object{
{Key: "t", Val: "auth"}, {Key: "node_id", Val: a.c.NodeID},
{Key: "sig", Val: b64.EncodeToString(ed25519.Sign(a.nodeKey, authMsg(nonce, a.c.NodeID)))},
}
if s := a.secret(); s != "" {
auth = append(auth, jsjson.KV{Key: "join", Val: jsjson.Object{{Key: "secret", Val: s}, {Key: "pub", Val: a.nodePubRaw}}})
}
if a.c.Version != "" {
auth = append(auth, jsjson.KV{Key: "version", Val: a.c.Version})
}
send(auth)
case t == "ready":
ready = true
armDead()
if onReady != nil {
onReady()
}
if a.secret() != "" {
a.forgetJoinSecret()
a.c.Logf("agent: нода привязана к core")
}
a.c.Logf("agent: подключено к core (%s)", a.addr())
// Подтверждение — только когда адрес уже на диске: по нему панель решает, что
// старый сервер можно выключать.
if core, ok := m.Get2("core").(string); ok && a.learnCore(core) {
send(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: core}})
}
case t == "req" && ready:
id, ok := m.Get2("id").(float64)
if !ok || id != float64(int64(id)) {
continue
}
// Запросы независимы, как async-обработчики в Node: медленный /awg/… не держит очередь.
go func() {
r := a.execute(m)
send(jsjson.Object{{Key: "t", Val: "res"}, {Key: "id", Val: id}, {Key: "status", Val: r.status}, {Key: "data", Val: r.data}})
}()
}
}
}
func hostPort(host string, port int) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
host = "[" + host + "]"
}
return host + ":" + strconv.Itoa(port)
}
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
func (a *Agent) Run(ctx context.Context) error {
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr())
backoff := time.Second
for {
ready := false
authFailed := a.connectOnce(ctx, func() { backoff, ready = time.Second, true })
if !ready {
a.next()
}
wait := backoff
if authFailed {
wait = maxBackoff
}
backoff = min(backoff*2, maxBackoff)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
}
+353
View File
@@ -0,0 +1,353 @@
package agent
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
func TestPathAllowed(t *testing.T) {
for p, want := range map[any]bool{
"/health": true,
"/api/users": true,
"/api/users/a?x=1": true,
"/awg/upgrade": true,
"/healthz": false,
"/api": false,
"/etc/passwd": false,
"//evil.com/api/x": false,
`/\evil.com/api/x`: false,
"/api/../etc": false,
"/api/%2e%2E/etc": false,
"/api/./x": false,
"/api/a b": false,
"api/users": false,
42: false,
"/api/" + strings.Repeat("a", 2048): false,
} {
if _, got := pathAllowed(p); got != want {
t.Errorf("pathAllowed(%v) = %v, want %v", p, got, want)
}
}
}
func writeKey(t *testing.T, file string, priv ed25519.PrivateKey) {
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatal(err)
}
os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
}
type core struct {
srv *httptest.Server
frames chan jsjson.Object // всё, что прислал агент
script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)
}
func newCore(t *testing.T, script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)) *core {
c := &core{frames: make(chan jsjson.Object, 16), script: script}
c.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/node/v1" {
http.NotFound(w, r)
return
}
ws, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer ws.CloseNow()
ctx := r.Context()
read := func() jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
return nil
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
c.frames <- o
return o
}
c.script(ctx, ws, read)
}))
t.Cleanup(c.srv.Close)
return c
}
func (c *core) pin(t *testing.T) string {
p, err := spkiPin(c.srv.Certificate().Raw)
if err != nil {
t.Fatal(err)
}
return p
}
func (c *core) hostPort() (string, int) {
u, _ := url.Parse(c.srv.URL)
port, _ := strconv.Atoi(u.Port())
return u.Hostname(), port
}
func newAgent(t *testing.T, c *core, pin, ctrlURL string) (*Agent, string) {
dir := t.TempDir()
host, port := c.hostPort()
_, ik, _ := ed25519.GenerateKey(rand.Reader)
writeKey(t, filepath.Join(dir, "internal.key"), ik)
envFile := filepath.Join(dir, "cli.env")
os.WriteFile(envFile, []byte("CORE_HOST=x\nJOIN_SECRET=s3cret\nNODE_ID=7\n"), 0o600)
a, err := New(Config{
CoreHost: host, CorePort: port, CorePin: pin, NodeID: 7, Ctrl: ctrlURL,
NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"),
CLIEnvFile: envFile, JoinSecret: "s3cret", Version: "Beta 0.3.4",
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
return a, envFile
}
func send(ctx context.Context, ws *websocket.Conn, v any) {
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(v))
}
func TestHandshakeAndRequests(t *testing.T) {
var internalPub ed25519.PublicKey
ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if !iauth.Verify(internalPub, tok, time.Now()) {
w.WriteHeader(401)
io.WriteString(w, `{"error":"Неверная авторизация"}`)
return
}
body, _ := io.ReadAll(r.Body)
switch r.URL.Path {
case "/api/users":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
io.WriteString(w, `{"got":`+string(body)+`, "q":"`+r.URL.RawQuery+`"}`)
case "/health":
io.WriteString(w, "ok")
default:
w.WriteHeader(204)
}
}))
defer ctrl.Close()
results := make(chan jsjson.Object, 8)
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n0nce"}})
auth := read()
if auth == nil {
return
}
join, _ := auth.Get2("join").(jsjson.Object)
pubRaw, _ := b64.DecodeString(join.Get2("pub").(string))
sig, _ := b64.DecodeString(auth.Get2("sig").(string))
if !ed25519.Verify(pubRaw, authMsg("n0nce", 7), sig) {
ws.Close(4003, "unauthorized")
return
}
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 1}, {Key: "method", Val: "post"},
{Key: "path", Val: "/api/users?x=1"}, {Key: "body", Val: jsjson.Object{{Key: "name", Val: "alice"}}}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/etc/passwd"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/health"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 4}, {Key: "method", Val: "DELETE"}, {Key: "path", Val: "/api/x"}})
for range 4 {
if r := read(); r != nil {
results <- r
}
}
ws.Close(websocket.StatusNormalClosure, "")
})
a, envFile := newAgent(t, c, c.pin(t), ctrl.URL)
internalPub = a.internalKey.Public().(ed25519.PublicKey)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if a.connectOnce(ctx, nil) {
t.Fatal("authFailed on a good handshake")
}
auth := <-c.frames
want := `{"t":"auth","node_id":7,"sig":"` + auth.Get2("sig").(string) + `","join":{"secret":"s3cret","pub":"` + a.nodePubRaw + `"},"version":"Beta 0.3.4"}`
if got := string(jsjson.MustMarshal(auth)); got != want {
t.Errorf("auth frame:\n got %s\nwant %s", got, want)
}
byID := map[float64]string{}
for range 4 {
r := <-results
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
}
for id, want := range map[float64]string{
1: `{"t":"res","id":1,"status":201,"data":{"got":{"name":"alice"},"q":"x=1"}}`,
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
3: `{"t":"res","id":3,"status":200,"data":"ok"}`,
4: `{"t":"res","id":4,"status":204,"data":""}`,
} {
if byID[id] != want {
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
}
}
// Секрет одноразовый: после ready он исчезает из памяти и из cli.env.
if a.secret() != "" {
t.Error("join secret still in memory")
}
if raw, _ := os.ReadFile(envFile); strings.Contains(string(raw), "JOIN_SECRET") || !strings.Contains(string(raw), "NODE_ID=7") {
t.Errorf("cli.env after join: %q", raw)
}
}
func TestWrongPinSendsNothing(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
})
a, _ := newAgent(t, c, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "http://127.0.0.1:1")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if a.connectOnce(ctx, nil) {
t.Fatal("pin mismatch reported as auth failure")
}
select {
case f := <-c.frames:
t.Fatalf("agent sent %v to a core with a foreign certificate", f)
default:
}
}
func TestAuthRejected(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
ws.Close(4003, "unauthorized")
})
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if !a.connectOnce(ctx, nil) {
t.Fatal("4003 not reported as auth failure")
}
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "JOIN_SECRET=s3cret") {
t.Error("join secret dropped without a successful join")
}
}
func TestNodeKeyPersists(t *testing.T) {
file := filepath.Join(t.TempDir(), "node.key")
k1, err := loadNodeKey(file, t.Logf)
if err != nil {
t.Fatal(err)
}
k2, _ := loadNodeKey(file, t.Logf)
if !k1.Equal(k2) {
t.Fatal("node key regenerated")
}
if st, _ := os.Stat(file); st.Mode().Perm() != 0o600 {
t.Fatalf("node.key mode %v", st.Mode().Perm())
}
}
// Нода #0 — сервер, с которого переехала панель: 0 — значение, а не «не задан».
func TestConfigNodeZero(t *testing.T) {
t.Setenv("CORE_HOST", "203.0.113.7")
t.Setenv("CORE_PORT", "40500")
t.Setenv("CORE_PIN", "pin")
for id, ok := range map[string]bool{"0": true, "3": true, "": false, "-1": false, "x": false} {
t.Setenv("NODE_ID", id)
c, err := ConfigFromEnv()
if (err == nil) != ok {
t.Errorf("NODE_ID=%q: %v", id, err)
}
if ok && strconv.Itoa(c.NodeID) != id {
t.Errorf("NODE_ID=%q → %d", id, c.NodeID)
}
}
}
// Панель переехала: core сообщает свой адрес в ready, агент записывает его в cli.env,
// подтверждает (core_ok) и с тех пор звонит туда первым, а адрес из join — запасной.
func TestLearnsCoreAddress(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}, {Key: "core", Val: "198.51.100.9:40500"}})
read()
ws.Close(websocket.StatusNormalClosure, "")
})
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
host, port := c.hostPort()
join := hostPort(host, port)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
a.connectOnce(ctx, nil)
<-c.frames // auth
if ok := <-c.frames; string(jsjson.MustMarshal(ok)) != `{"t":"core_ok","core":"198.51.100.9:40500"}` {
t.Errorf("подтверждение: %s", jsjson.MustMarshal(ok))
}
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "CORE_ENDPOINT=198.51.100.9:40500") {
t.Errorf("cli.env: %q", raw)
}
if strings.Join(a.addrs, " ") != "198.51.100.9:40500 "+join || a.addr() != join {
t.Errorf("адреса %v, текущий %s", a.addrs, a.addr())
}
}
func TestBadCoreAddressIgnored(t *testing.T) {
a := &Agent{c: Config{CoreHost: "203.0.113.1", CorePort: 40500, CLIEnvFile: filepath.Join(t.TempDir(), "cli.env"), Logf: t.Logf, Errorf: t.Logf}}
a.setAddrs("")
for _, bad := range []string{"", "x", "host:0", "host:99999", "a b:1", "host:1/x", "[::1]:x"} {
if a.learnCore(bad) {
t.Errorf("принят адрес %q", bad)
}
}
if !a.learnCore("[2001:db8::1]:443") || a.addrs[0] != "[2001:db8::1]:443" {
t.Errorf("IPv6: %v", a.addrs)
}
}
// Записанный адрес не отвечает (старый сервер выключен или новый ещё не открыт) — следующая
// попытка идёт на адрес из join.
func TestFallsBackToJoinAddress(t *testing.T) {
connected := make(chan struct{}, 1)
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
connected <- struct{}{}
<-ctx.Done()
})
a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
a.c.CoreEndpoint = "127.0.0.1:1"
a.setAddrs(a.c.CoreEndpoint)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go a.Run(ctx)
select {
case <-connected:
case <-ctx.Done():
t.Fatal("агент не перешёл на адрес из join")
}
}
+650
View File
@@ -0,0 +1,650 @@
// Package cli — менеджер процессов Forgetting (порт cli/src): start/stop/restart/status,
// логин и пароль панели, join/unjoin ноды, пиры подписки, встроенный Prometheus и
// интерактивное меню. Сервисы — тот же бинарник с ролью: `awg-control ctrl|ui|agent`.
package cli
import (
"bufio"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson"
)
// ── Цвета ───────────────────────────────────────────────────────────────────
const (
cReset = "\x1b[0m"
cBold = "\x1b[1m"
cDim = "\x1b[2m"
cGreen = "\x1b[32m"
cGrey = "\x1b[90m"
cBlue = "\x1b[34m"
)
func bold(s string) string { return cBold + s + cReset }
func green(s string) string { return cGreen + s + cReset }
func grey(s string) string { return cGrey + s + cReset }
func dim(s string) string { return cDim + s + cReset }
func errorf(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
// ── Окружение ───────────────────────────────────────────────────────────────
// findRoot — корень установки: AWG_ROOT, иначе ближайший предок каталога бинарника, где есть
// cli/ или .env, иначе /opt/awg-control.
func findRoot() string {
if r := os.Getenv("AWG_ROOT"); r != "" {
return r
}
if exe, err := os.Executable(); err == nil {
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
for d := filepath.Dir(exe); d != "/" && d != "."; d = filepath.Dir(d) {
if exists(filepath.Join(d, "cli")) || exists(filepath.Join(d, ".env")) {
return d
}
}
}
return "/opt/awg-control"
}
func exists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
// loadEnvFile — уже заданное окружение не перетираем (??= в Node), поэтому порядок =
// приоритет: реальное окружение → cli.env → .env в корне проекта.
func loadEnvFile(file string) {
raw, err := os.ReadFile(file)
if err != nil {
return
}
for _, line := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
i := strings.Index(t, "=")
if i < 0 {
continue
}
k := strings.TrimSpace(t[:i])
if _, set := os.LookupEnv(k); !set {
os.Setenv(k, strings.TrimSpace(t[i+1:]))
}
}
}
func env(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
// envOr — `process.env.X || def`.
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
type service struct {
name string // ctrl | ui | agent — роль бинарника
label string
cwd string
legacy string // entry Node-версии: её процессы тоже узнаём (переход с TS)
env [][2]string
pidFile string
logFile string
}
type CLI struct {
root string
logs string
envFile string
origEnv []string // окружение до чтения cli.env и .env — для свежего перезапуска
exe string
brand, channel, version string
svc map[string]*service
all []string
isNode bool
in *bufio.Reader
}
func New() *CLI {
c := &CLI{root: findRoot(), origEnv: os.Environ(), in: bufio.NewReader(os.Stdin)}
c.exe, _ = os.Executable()
c.logs = filepath.Join(c.root, "logs")
os.MkdirAll(c.logs, 0o755)
c.envFile = filepath.Join(c.root, "cli", "cli.env")
loadEnvFile(c.envFile) // машинные настройки
loadEnvFile(filepath.Join(c.root, ".env")) // имя панели и версия
c.brand = envOr("BRAND", "Forgetting")
c.channel = envOr("CHANNEL", "Beta")
c.version = envOr("VERSION", "")
cliEnv := c.envFile
coreHost := os.Getenv("CORE_HOST")
audit := "off"
if coreHost == "" {
audit = env("AUDIT", "on")
}
c.svc = map[string]*service{
"awgctrl": {
name: "ctrl", label: "awg-ctrl", cwd: filepath.Join(c.root, "awg-ctrl"), legacy: "index.ts",
env: [][2]string{
{"PORT", env("AWGCTRL_PORT", "3005")},
{"SERVER_IP", env("SERVER_IP", "")},
{"SERVER_PORT", env("SERVER_PORT", "47619")},
{"SERVER_NAME", env("SERVER_NAME", "VPN")},
// Публичный ключ внутренней авторизации (awg-ctrl проверяет им токены awg-ui).
{"INTERNAL_AUTH_PUB_FILE", env("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")},
},
pidFile: "/tmp/awg-ctrl.pid", logFile: filepath.Join(c.logs, "awg-ctrl.log"),
},
"ui": {
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
env: [][2]string{
{"PORT", env("UI_PORT", "8080")},
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
{"BRAND", c.brand}, {"CHANNEL", c.channel}, {"VERSION", c.version},
{"UI_USER", env("UI_USER", "admin")},
{"UI_PASS", env("UI_PASS", "")},
{"JWT_SECRET", env("JWT_SECRET", "")},
// ui.db в каталоге данных AWG (вне проекта) — переживает переустановку.
{"UI_DB_FILE", env("UI_DB_FILE", "/etc/amnezia/amneziawg/ui.db")},
// Подписка sen://: без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
{"SUB_PORT", env("SUB_PORT", "")},
{"SUB_TLS", env("SUB_TLS", "off")},
{"SUB_SIGN_KEY_FILE", env("SUB_SIGN_KEY_FILE", "/etc/amnezia/amneziawg/sub_sign.key")},
{"SUB_TLS_KEY_FILE", env("SUB_TLS_KEY_FILE", "/etc/amnezia/amneziawg/sub_tls.key")},
{"SUB_TLS_CERT_FILE", env("SUB_TLS_CERT_FILE", "/etc/amnezia/amneziawg/sub_tls.crt")},
{"SUB_HOST", env("SUB_HOST", "")},
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
{"NODE_PORT", env("NODE_PORT", "")},
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
{"RELAY_FROM", env("RELAY_FROM", "")},
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
{"CLI_ENV_FILE", cliEnv},
// Часовой аудит пиров на всех серверах — только у core.
{"AUDIT", audit},
{"METRICS_PEERS", env("METRICS_PEERS", "off")},
{"LOCAL_NODE", env("LOCAL_NODE", "on")},
{"NODE_TLS_KEY_FILE", env("NODE_TLS_KEY_FILE", "/etc/amnezia/amneziawg/node_tls.key")},
{"NODE_TLS_CERT_FILE", env("NODE_TLS_CERT_FILE", "/etc/amnezia/amneziawg/node_tls.crt")},
},
pidFile: "/tmp/awg-ui.pid", logFile: filepath.Join(c.logs, "awg-ui.log"),
},
// Агент ноды: сам звонит в core и исполняет запросы на локальном awg-ctrl.
"agent": {
name: "agent", label: "awg-agent", cwd: filepath.Join(c.root, "awg-agent"), legacy: "index.ts",
env: [][2]string{
{"CORE_HOST", env("CORE_HOST", "")},
{"CORE_PORT", env("CORE_PORT", "")},
{"CORE_ENDPOINT", env("CORE_ENDPOINT", "")},
{"CORE_PIN", env("CORE_PIN", "")},
{"NODE_ID", env("NODE_ID", "")},
{"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")},
{"JOIN_SECRET", env("JOIN_SECRET", "")},
{"CLI_ENV_FILE", cliEnv},
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
},
pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"),
},
// Ретранслятор на сервере, с которого переехала панель: прежние порты → новая панель.
"relay": {
name: "relay", label: "awg-relay", cwd: c.root,
env: [][2]string{{"RELAY", env("RELAY", "")}},
pidFile: "/tmp/awg-relay.pid", logFile: filepath.Join(c.logs, "awg-relay.log"),
},
}
// Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с
// LOCAL_NODE=off — панель без своего VPN; иначе — обычная установка. Нода со своей панелью
// (join без --no-panel) держит и awg-ui: обе панели ходят в один awg-ctrl.
c.isNode = coreHost != ""
noLocal := strings.ToLower(env("LOCAL_NODE", "on")) == "off"
nodePanel := c.isNode && os.Getenv("UI_PASS") != "" && strings.ToLower(env("NODE_PANEL", "on")) != "off"
switch {
case c.isNode && nodePanel:
c.all = []string{"awgctrl", "ui", "agent"}
case c.isNode:
c.all = []string{"awgctrl", "agent"}
case noLocal:
c.all = []string{"ui"}
default:
c.all = []string{"awgctrl", "ui"}
}
// Панель переехала (`migrate export`): здесь её больше не запускаем — вторая копия ui.db
// разошлась бы с перенесённой.
if c.panelMoved() {
c.all = without(c.all, "ui")
}
if os.Getenv("RELAY") != "" {
c.all = append(c.all, "relay")
}
return c
}
func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" }
func without(s []string, v string) []string {
var out []string
for _, x := range s {
if x != v {
out = append(out, x)
}
}
return out
}
func (c *CLI) envOf(name string) string {
for _, kv := range c.svc[name].env {
if kv[0] == "PORT" {
return kv[1]
}
}
return ""
}
func (c *CLI) svcEnv(name, key string) string {
for _, kv := range c.svc[name].env {
if kv[0] == key {
return kv[1]
}
}
return ""
}
// ── Процессы ────────────────────────────────────────────────────────────────
func (c *CLI) readPid(name string) int {
raw, err := os.ReadFile(c.svc[name].pidFile)
if err != nil {
return 0
}
n, err := strconv.Atoi(firstInt(strings.TrimSpace(string(raw))))
if err != nil {
return 0
}
return n
}
var reLeadInt = regexp.MustCompile(`^[+-]?\d+`)
// firstInt — parseInt: ведущее целое, остальное игнорируется.
func firstInt(s string) string { return reLeadInt.FindString(s) }
// isAlive — EPERM: процесс есть, просто не наш (считаем живым).
func isAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
// pidMatches — защита от переиспользования PID: сверяем cmdline с сервисом. Узнаём и Go-сервис
// (`awg-control <роль>`), и процесс Node-версии (entry-файл в cmdline). Без procfs проверить нечем.
func (c *CLI) pidMatches(name string, pid int) bool {
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
return true
}
args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
s := c.svc[name]
if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name {
return true
}
if s.legacy == "" {
return false
}
// tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent.
return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd))
}
func (c *CLI) isRunning(name string) bool {
pid := c.readPid(name)
return pid != 0 && isAlive(pid) && c.pidMatches(name, pid)
}
func (c *CLI) start(name string) {
s := c.svc[name]
if c.isRunning(name) {
fmt.Printf(" %s: already running pid=%d\n", s.label, c.readPid(name))
return
}
if name == "awgctrl" {
if f := c.svcEnv("awgctrl", "INTERNAL_AUTH_PUB_FILE"); !exists(f) {
errorf(" awgctrl: публичный ключ внутренней авторизации не найден: %s", f)
os.Exit(1)
}
}
logF, err := os.OpenFile(s.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
errorf(" %s: не удалось запустить — %v", s.label, err)
return
}
defer logF.Close() // дескриптор унаследован ребёнком — родителю не нужен
cmd := exec.Command(c.exe, s.name)
cmd.Dir = s.cwd
if !exists(s.cwd) {
cmd.Dir = c.root
}
cmd.Env = os.Environ()
for _, kv := range s.env {
cmd.Env = append(cmd.Env, kv[0]+"="+kv[1])
}
cmd.Stdout, cmd.Stderr = logF, logF
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // detached: переживает CLI
if err := cmd.Start(); err != nil {
errorf(" %s: не удалось запустить — %v", s.label, err)
os.Remove(s.pidFile)
return
}
go cmd.Wait() // пока CLI жив (меню), не оставляем зомби
os.WriteFile(s.pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644)
fmt.Printf(" %s %s: started pid=%d\n", green("▶"), s.label, cmd.Process.Pid)
}
func (c *CLI) stop(name string) {
s := c.svc[name]
pid := c.readPid(name)
if pid == 0 || !isAlive(pid) || !c.pidMatches(name, pid) {
fmt.Printf(" %s: not running\n", s.label)
os.Remove(s.pidFile)
return
}
syscall.Kill(pid, syscall.SIGTERM)
// Ждём мягкого завершения до 5 с, иначе добиваем SIGKILL.
deadline := time.Now().Add(5 * time.Second)
for isAlive(pid) && !isZombie(pid) && time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
}
if isAlive(pid) && !isZombie(pid) {
syscall.Kill(pid, syscall.SIGKILL)
time.Sleep(200 * time.Millisecond)
}
os.Remove(s.pidFile)
fmt.Printf(" %s %s: stopped pid=%d\n", grey("■"), s.label, pid)
}
// isZombie — процесс завершился, но его ещё не забрал родитель (наш же Wait в меню).
func isZombie(pid int) bool {
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return false
}
s := string(raw)
if i := strings.LastIndex(s, ")"); i >= 0 && i+2 < len(s) {
return s[i+2] == 'Z'
}
return false
}
// fetchHealth — GET /health и разбор JSON. Любая ошибка — nil.
func fetchHealth(url string) any {
cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get(url)
if err != nil {
return nil
}
defer resp.Body.Close()
var b strings.Builder
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
b.WriteString(string(buf[:n]))
if err != nil {
break
}
}
v, err := jsjson.Decode([]byte(b.String()))
if err != nil {
return nil
}
return v
}
func get(v any, keys ...string) any {
for _, k := range keys {
o, ok := v.(jsjson.Object)
if !ok {
return nil
}
v = o.Get2(k)
}
return v
}
func strOr(v any, def string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return def
}
func (c *CLI) serviceLine(name string, menu bool) string {
running := c.isRunning(name)
dot, info := grey("○"), grey("stopped")
if running {
dot = green("●")
if menu {
info = green("running") + dim(fmt.Sprintf(" pid %d", c.readPid(name)))
} else {
info = green("running") + fmt.Sprintf(" pid %d", c.readPid(name))
}
}
return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info)
}
func (c *CLI) status() {
fmt.Println()
for _, n := range c.all {
fmt.Println(c.serviceLine(n, false))
}
if c.isRunning("awgctrl") {
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
if gen, ok := get(h, "gen").(string); ok && gen != "" {
g := "2.0"
if gen == "3.1" {
g = "3.1"
}
fmt.Println(" " + dim(fmt.Sprintf("AmneziaWG %s · модуль %s · tools %s", g, strOr(get(h, "awg", "module"), "?"), strOr(get(h, "awg", "tools"), "?"))))
}
}
fmt.Println()
}
// probe — любой HTTP-ответ (даже 401/503) значит, что порт слушается.
func probe(url string) bool {
cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get(url)
if err != nil {
return false
}
resp.Body.Close()
return true
}
// waitForHealth — ждём, пока awg-ctrl начнёт отвечать; умер — сразу выходим.
func (c *CLI) waitForHealth() bool {
url := "http://127.0.0.1:" + c.envOf("awgctrl") + "/health"
for range 10 {
if !c.isRunning("awgctrl") {
return false
}
if probe(url) {
return true
}
time.Sleep(300 * time.Millisecond)
}
return false
}
func (c *CLI) startAll() {
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
if !contains(c.all, "awgctrl") {
for _, n := range c.all {
c.start(n)
}
return
}
// Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN.
if contains(c.all, "relay") {
c.start("relay")
}
c.start("awgctrl")
var rest, labels []string
for _, n := range c.all {
if n != "awgctrl" && n != "relay" {
rest = append(rest, n)
labels = append(labels, c.svc[n].label)
}
}
if !c.isRunning("awgctrl") || !c.waitForHealth() {
errorf(" %s awg-ctrl не отвечает — %s не запущен (см. %s)", grey("■"), strings.Join(labels, ", "), c.svc["awgctrl"].logFile)
return
}
for _, n := range rest {
c.start(n)
}
}
func (c *CLI) stopAll() {
for _, n := range c.all {
c.stop(n)
}
}
func (c *CLI) restartAll() {
c.stopAll()
c.startAll()
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ── cli.env ─────────────────────────────────────────────────────────────────
func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) }
func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
func (c *CLI) readEnvFile() string {
raw, _ := os.ReadFile(c.envFile)
return string(raw)
}
// writeEnvFile — новый файл с правами 0600 (в нём пароль панели); у существующего права не трогаем.
func (c *CLI) writeEnvFile(content string) error {
return os.WriteFile(c.envFile, []byte(content), 0o600)
}
// restartFresh — роль читается из окружения при старте процесса, поэтому после смены cli.env
// перезапуск — свежим процессом с исходным окружением (без значений из старого cli.env).
func (c *CLI) restartFresh() {
cmd := exec.Command(c.exe, "restart")
cmd.Env = c.origEnv
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Run()
}
// ── Ввод ────────────────────────────────────────────────────────────────────
func (c *CLI) ask(prompt string) string {
fmt.Print(prompt)
line, _ := c.in.ReadString('\n')
return strings.TrimRight(line, "\r\n")
}
var reYes = regexp.MustCompile(`(?i)^y`)
func (c *CLI) confirmYes(q string) bool {
return reYes.MatchString(strings.TrimSpace(c.ask(q + " [y/N] ")))
}
// ── Точка входа ─────────────────────────────────────────────────────────────
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>>"
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"}
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
func Main(args []string) {
c := New()
if len(args) == 0 {
c.interactiveMenu()
return
}
arg := func(i int) (string, bool) {
if i < len(args) {
return args[i], true
}
return "", false
}
sub, hasSub := arg(1)
a2, hasA2 := arg(2)
switch args[0] {
case "start":
c.startAll()
case "stop":
c.stopAll()
case "restart":
c.restartAll()
case "status":
c.status()
case "credentials":
switch sub {
case "user", "pass":
c.setCredentials(sub)
default:
c.setCredentials("")
}
case "join":
c.joinCore(sub, hasSub, a2, hasA2)
case "unjoin":
c.unjoinCore()
case "rehome":
c.rehome(sub, hasSub)
case "migrate":
c.migrateCmd(sub, a2, hasA2)
case "peers":
c.peersCmd(sub, hasSub, a2, hasA2)
case "monitor":
c.monitorCmd(sub)
default:
fmt.Println(usage)
os.Exit(1)
}
}
+44
View File
@@ -0,0 +1,44 @@
package cli
import "testing"
func TestUpdateEnvVar(t *testing.T) {
for _, c := range []struct{ in, key, val, want string }{
{"", "A", "1", "\nA=1\n"},
{"A=0\nB=2\n", "A", "1", "A=1\nB=2\n"},
{"B=2\n\n\n", "A", "1", "B=2\nA=1\n"},
{"XA=0\nA=0\n", "A", "$&1", "XA=0\nA=$&1\n"},
{"A=0\nA=9\n", "A", "1", "A=1\nA=9\n"}, // как String.replace без g — только первое
} {
if got := updateEnvVar(c.in, c.key, c.val); got != c.want {
t.Errorf("updateEnvVar(%q, %s, %s) = %q, want %q", c.in, c.key, c.val, got, c.want)
}
}
if got := removeEnvVar("A=1\nJOIN_SECRET=x\nB=2\n", "JOIN_SECRET"); got != "A=1\nB=2\n" {
t.Errorf("removeEnvVar: %q", got)
}
}
func TestAreaOf(t *testing.T) {
for owner, want := range map[string]string{
"abcdef123456/m1/d2": "abcdef123456",
"m1/d2": "legacy",
"alice": "",
"ABCDEF123456/m1/d2": "",
"abc/m1/d2": "",
"": "",
} {
if got := areaOf(owner); got != want {
t.Errorf("areaOf(%q) = %q, want %q", owner, got, want)
}
}
}
func TestPeersWord(t *testing.T) {
for n, want := range map[int]string{0: "пиров", 1: "пир", 2: "пира", 4: "пира", 5: "пиров", 11: "пиров", 12: "пиров",
21: "пир", 22: "пира", 101: "пир", 111: "пиров", 114: "пиров", 1004: "пира"} {
if got := peersWord(n); got != want {
t.Errorf("peersWord(%d) = %s, want %s", n, got, want)
}
}
}
+525
View File
@@ -0,0 +1,525 @@
package cli
import (
"bytes"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64url = base64.RawURLEncoding
// fromB64url — Buffer.from(s, "base64url"): нестрого, как в Node.
func fromB64url(s string) []byte {
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
if len(s)%4 == 1 {
s = s[:len(s)-1]
}
b, _ := b64url.DecodeString(s)
return b
}
// ── join / unjoin ───────────────────────────────────────────────────────────
var (
reJoinHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`)
reJoinPin = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
reJoinSec = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
)
func isPosInt(v any, max float64) (int64, bool) {
f, ok := v.(float64)
return int64(f), ok && f == float64(int64(f)) && f > 0 && f < max
}
// joinCore — `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как
// ноду. Рядом запускается агент; awg-ctrl и пользователи остаются как были. Своя панель
// продолжает работать, если она здесь есть, — с --no-panel она останавливается.
func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
if !hasLink || !strings.HasPrefix(link, "awgjoin://") || hasFlag && flag != "--no-panel" {
errorf(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)")
os.Exit(1)
}
v, err := jsjson.Decode(fromB64url(link[len("awgjoin://"):]))
j, isObj := v.(jsjson.Object)
if err != nil || !isObj {
errorf(" Ссылка повреждена")
os.Exit(1)
}
h, hok := j.Get2("h").(string)
pin, pinok := j.Get2("pin").(string)
sec, sok := j.Get2("s").(string)
port, pok := isPosInt(j.Get2("p"), 65536)
id, iok := isPosInt(j.Get2("n"), 9007199254740992)
// Нода #0 — этот же сервер после переноса панели (`migrate import` выдаёт строку с n = 0).
if n, ok := j.Get2("n").(float64); ok && n == 0 {
id, iok = 0, true
}
if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) ||
!iok || !sok || !reJoinSec.MatchString(sec) {
errorf(" Ссылка неверного формата или от другой версии панели")
os.Exit(1)
}
if c.isNode && !c.confirmYes(fmt.Sprintf(" Эта нода уже подключена к %s. Перепривязать?", os.Getenv("CORE_HOST"))) {
return
}
hasPanel := os.Getenv("UI_PASS") != ""
moved := c.panelMoved()
keepPanel := !hasFlag && hasPanel && !moved
content := c.readEnvFile()
np := "off"
if keepPanel {
np = "on"
}
for _, kv := range [][2]string{{"CORE_HOST", h}, {"CORE_PORT", fmt.Sprint(port)}, {"CORE_PIN", pin}, {"NODE_ID", fmt.Sprint(id)},
{"JOIN_SECRET", sec}, {"NODE_PANEL", np}} {
content = updateEnvVar(content, kv[0], kv[1])
}
content = removeEnvVar(content, "CORE_ENDPOINT") // адрес прежней панели к новой не относится
relay := ""
if moved {
relay = relayRoutes(h, port, j)
content = updateEnvVar(content, "RELAY", relay)
}
c.writeEnvFile(content)
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
// После переноса этими пирами управляет новая панель: снимать нечего.
dropPeers := !keepPanel && hasPanel && !moved && c.askDropPanelPeers()
if !keepPanel {
c.stop("ui")
}
if dropPeers {
c.dropArea(c.localPanelID())
}
tail := ""
if keepPanel {
tail = " · своя панель продолжает работать"
} else if hasPanel {
tail = " · своя панель остановлена"
}
fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail)
if relay != "" {
fmt.Println(" " + dim("Ретранслятор прежних портов на новую панель: "+relay))
}
c.restartFresh()
}
// relayRoutes — прежние порты этого сервера → новая панель. Подписка и хаб там на тех же
// портах (так их ставит `migrate import`), порт панели — из поля ui join-строки.
func relayRoutes(host string, hubPort int64, j jsjson.Object) string {
hp := func(p any) string { return net.JoinHostPort(host, fmt.Sprint(p)) }
var routes []string
add := func(local int, target string) {
if local > 0 {
routes = append(routes, fmt.Sprintf("%d=%s", local, target))
}
}
sub := envPort("SUB_PORT")
if p, ok := isPosInt(j.Get2("sub"), 65536); ok {
add(sub, hp(p))
} else {
add(sub, hp(sub))
}
add(envPort("NODE_PORT"), hp(hubPort))
if p, ok := isPosInt(j.Get2("ui"), 65536); ok {
add(envPort("UI_PORT"), hp(p))
}
return strings.Join(routes, ",")
}
func (c *CLI) unjoinCore() {
if !c.isNode {
fmt.Println(" Эта установка не подключена к core")
return
}
c.stop("agent")
content := c.readEnvFile()
for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL", "CORE_ENDPOINT"} {
content = removeEnvVar(content, k)
}
c.writeEnvFile(content)
fmt.Printf(" %s Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart\n", green("✓"))
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
if os.Getenv("UI_PASS") == "" {
fmt.Println(" " + dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials"))
}
}
// ── Пиры подписки (sen://) на локальном awg-ctrl ────────────────────────────
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели. Пиры
// без области («m1/d2») выданы до областей. Здесь — ручной рычаг для оператора: панель
// выключена или core пропал насовсем. По времени ничего не удаляется.
var (
reArea = regexp.MustCompile(`^[a-z0-9]{8,16}$`)
reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`)
)
func (c *CLI) localPanelID() string {
raw, err := os.ReadFile(filepath.Join(c.root, "awg-ui", "panel.id"))
if err != nil {
return ""
}
if id := strings.TrimSpace(string(raw)); reArea.MatchString(id) {
return id
}
return ""
}
type ctrlResp struct {
status int
data any
}
// ctrlCall — запрос к локальному awg-ctrl с тем же короткоживущим токеном, что у панели и агента.
func (c *CLI) ctrlCall(method, path string, body any) (*ctrlResp, error) {
key, err := iauth.LoadPrivate(c.svcEnv("ui", "INTERNAL_AUTH_KEY_FILE"))
if err != nil {
return nil, err
}
var rd io.Reader
if body != nil {
rd = bytes.NewReader(jsjson.MustMarshal(body))
}
req, err := http.NewRequest(method, "http://127.0.0.1:"+c.envOf("awgctrl")+path, rd)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+iauth.Mint(key, "awg-cli", time.Now()))
req.Header.Set("Content-Type", "application/json")
cl := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
data, err := jsjson.Decode(raw)
if err != nil {
data = nil
}
return &ctrlResp{resp.StatusCode, data}, nil
}
type ctrlPeer struct {
pub, ip, owner string
online bool
}
func ctrlError(r *ctrlResp) error {
if e, ok := get(r.data, "error").(string); ok {
return fmt.Errorf("%s", e)
}
return fmt.Errorf("awg-ctrl ответил %d", r.status)
}
func (c *CLI) listPeers() ([]ctrlPeer, error) {
r, err := c.ctrlCall("GET", "/api/peers", nil)
if err != nil {
return nil, err
}
if r.status != 200 {
return nil, ctrlError(r)
}
var out []ctrlPeer
list, _ := get(r.data, "peers").([]any)
for _, p := range list {
on, _ := get(p, "online").(bool)
out = append(out, ctrlPeer{pub: strOr(get(p, "pub_key"), ""), ip: strOr(get(p, "ip"), ""), owner: strOr(get(p, "owner"), ""), online: on})
}
return out, nil
}
// areaOf — область пира: id панели, «legacy» (до областей) или "" (чужой формат — не наш).
func areaOf(owner string) string {
head := strings.SplitN(owner, "/", 2)[0]
if strings.Contains(owner, "/") && reArea.MatchString(head) {
return head
}
if reLegacyOwner.MatchString(owner) {
return "legacy"
}
return ""
}
func peersWord(n int) string {
switch {
case n%10 == 1 && n%100 != 11:
return "пир"
case n%10 >= 2 && n%10 <= 4 && !(n%100 >= 12 && n%100 <= 14):
return "пира"
}
return "пиров"
}
func (c *CLI) askDropPanelPeers() bool {
own := c.localPanelID()
if own == "" {
return false
}
peers, err := c.listPeers()
if err != nil {
fmt.Fprintln(os.Stderr, " "+dim("Не удалось посмотреть пиров подписки: "+err.Error()))
return false
}
n := 0
for _, p := range peers {
if areaOf(p.owner) == own {
n++
}
}
if n == 0 {
return false
}
switch strings.ToLower(os.Getenv("NODE_PEERS")) {
case "keep":
return false
case "drop":
return true
}
fmt.Printf("\n На этой панели %d %s подписки (устройства мастер-ключей). Без панели ими некому управлять:\n", n, peersWord(n))
fmt.Println(" 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin")
fmt.Println(" 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)")
return strings.TrimSpace(c.ask(" Выбор [1]: ")) == "2"
}
// dropArea — снять всю область: для панели — пустым списком через sync, для legacy — по одному.
func (c *CLI) dropArea(area string) {
if area == "legacy" {
peers, err := c.listPeers()
if err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
n := 0
for _, p := range peers {
if areaOf(p.owner) != "legacy" {
continue
}
if _, err := c.ctrlCall("DELETE", "/api/peers/"+b64url.EncodeToString(stdB64(p.pub)), nil); err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
n++
}
fmt.Printf(" %s Снято пиров без области: %d\n", green("✓"), n)
return
}
r, err := c.ctrlCall("PUT", "/api/peers/sync", jsjson.Object{{Key: "owner", Val: area}, {Key: "keep", Val: []any{}}})
if err == nil && r.status != 200 {
err = ctrlError(r)
}
if err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
fmt.Printf(" %s Снято пиров области %s: %s\n", green("✓"), area, jsString(get(r.data, "removed")))
}
func stdB64(s string) []byte {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return fromB64url(s)
}
return b
}
// jsString — `${v}` для вывода числа или строки из ответа.
func jsString(v any) string {
switch x := v.(type) {
case nil:
return "undefined"
case string:
return x
case float64:
return jsjson.Number(x)
}
return string(jsjson.MustMarshal(v))
}
func (c *CLI) peersCmd(sub string, hasSub bool, arg string, hasArg bool) {
own := c.localPanelID()
peers, err := c.listPeers()
if err != nil {
errorf(" awg-ctrl недоступен: %v", err)
os.Exit(1)
}
var order []string
areas := map[string][]ctrlPeer{}
for _, p := range peers {
if a := areaOf(p.owner); a != "" {
if _, seen := areas[a]; !seen {
order = append(order, a)
}
areas[a] = append(areas[a], p)
}
}
if !hasSub {
if len(areas) == 0 {
fmt.Println(" Пиров подписки (sen://) на этом сервере нет")
return
}
fmt.Print("\n Пиры подписки (sen://) на этом сервере:\n\n")
for _, a := range order {
list := areas[a]
mark := ""
if a == own {
mark = "эта панель"
} else if a == "legacy" {
mark = "без области (выданы до областей)"
}
online := 0
for _, p := range list {
if p.online {
online++
}
}
fmt.Printf(" %s %4d %-5s онлайн %d %s\n", bold(fmt.Sprintf("%-16s", a)), len(list), peersWord(len(list)), online, dim(mark))
}
fmt.Printf("\n %s\n", dim("Снять область целиком: awg-ctrl peers drop <область>"))
return
}
if sub != "drop" || !hasArg || arg == "" || !(arg == "legacy" || reArea.MatchString(arg)) {
errorf(" Usage: awg-ctrl peers [drop <область|legacy>]")
os.Exit(1)
}
n := len(areas[arg])
if n == 0 {
fmt.Printf(" В области %s пиров нет\n", arg)
return
}
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
if arg == own && c.isRunning("ui") {
errorf(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней")
os.Exit(1)
}
fmt.Println(" " + dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —"))
fmt.Println(" " + dim("сперва удалите сервер в ней (или мастер-ключи)."))
if !c.confirmYes(fmt.Sprintf(" Снять %d %s области %s? Их устройства потеряют доступ к этому серверу.", n, peersWord(n), arg)) {
return
}
c.dropArea(arg)
}
// ── Логин и пароль панели ───────────────────────────────────────────────────
func (c *CLI) setCredentials(field string) {
var user, pass *string
if field == "" || field == "user" {
cur := env("UI_USER", "admin")
u := strings.TrimSpace(c.ask(fmt.Sprintf("\n Логин [%s]: ", cur)))
if u == "" {
u = cur
}
user = &u
}
if field == "" || field == "pass" {
b := make([]byte, 9)
rand.Read(b)
generated := b64url.EncodeToString(b)[:12]
p := strings.TrimSpace(c.ask(fmt.Sprintf(" Пароль [%s]: ", generated)))
if p == "" {
p = generated
}
pass = &p
}
content := c.readEnvFile()
if user != nil {
content = updateEnvVar(content, "UI_USER", *user)
os.Setenv("UI_USER", *user)
}
if pass != nil {
content = updateEnvVar(content, "UI_PASS", *pass)
os.Setenv("UI_PASS", *pass)
}
c.writeEnvFile(content)
if user != nil {
fmt.Printf("\n %s Логин: %s\n", green("✓"), *user)
}
if pass != nil {
fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass)
}
fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу."))
}
// ── Интерактивное меню ──────────────────────────────────────────────────────
func clearScreen() {
if st, err := os.Stdout.Stat(); err == nil && st.Mode()&os.ModeCharDevice != 0 {
fmt.Print("\x1b[1;1H\x1b[0J")
}
}
func (c *CLI) interactiveMenu() {
printMenu := func() {
clearScreen()
var parts []string
for _, p := range []string{c.brand, c.channel, c.version} {
if p != "" {
parts = append(parts, p)
}
}
fmt.Printf("\n%s\n\n", bold(cBlue+"── "+strings.Join(parts, " ")+" ──"+cReset))
for _, n := range c.all {
fmt.Println(c.serviceLine(n, true))
}
sep := dim("──────────────")
fmt.Printf(`
%s Запустить
%s Остановить
%s Перезапустить
%s Статус
%s
%s Сменить логин UI
%s Сменить пароль UI
%s
%s Выход
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("0"))
}
for {
printMenu()
fmt.Print(" › ")
choice, err := c.in.ReadString('\n')
if err != nil && choice == "" {
fmt.Println()
return // stdin закрыт
}
choice = strings.TrimSpace(choice)
fmt.Println()
switch choice {
case "1":
c.startAll()
case "2":
c.stopAll()
case "3":
c.restartAll()
case "4":
c.status()
case "5":
c.setCredentials("user")
case "6":
c.setCredentials("pass")
case "0":
os.Exit(0)
default:
fmt.Println(grey(" Неверный выбор"))
}
c.ask("\n " + dim("Enter для продолжения..."))
}
}
+292
View File
@@ -0,0 +1,292 @@
package cli
// `migrate export|import|cancel` — перенос панели с сервера «панель + VPN» на отдельную панель
// без VPN; архив и раскладка — internal/migrate (их же зовёт вкладка «Перенос»):
//
// старый: migrate export → архив: ui.db, ключи подписки и хаба, порты
// новый: migrate import → раскладывает архив, заводит ноду #0 и печатает join-строку
// старый: join '<строка>' → становится нодой #0 и поднимает ретранслятор прежних портов
import (
"crypto/rand"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func envPort(k string) int {
n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
if err != nil || n <= 0 || n > 65535 {
return 0
}
return n
}
func (c *CLI) paths() migrate.Paths {
return migrate.PathsFrom(func(env string) string { return c.svcEnv("ui", env) })
}
func (c *CLI) migrateCmd(sub, arg string, hasArg bool) {
switch sub {
case "export":
c.migrateExport(arg, hasArg)
case "import":
if !hasArg {
errorf(" Usage: awg-ctrl migrate import <архив>")
os.Exit(1)
}
c.migrateImport(arg)
case "cancel":
c.migrateCancel()
default:
errorf(" Usage: awg-ctrl migrate <export [файл]|import <архив>|cancel>")
os.Exit(1)
}
}
// ── export (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateExport(out string, hasOut bool) {
if c.isNode {
errorf(" Это нода: её панель — на core, переносить отсюда нечего")
os.Exit(1)
}
if !contains(c.svcList(), "awgctrl") {
errorf(" У этой панели нет своего VPN (LOCAL_NODE=off) — перенос рассчитан на сервер «панель + VPN»")
os.Exit(1)
}
dbFile := c.paths()["ui.db"]
if !exists(dbFile) {
errorf(" Нет базы панели: %s", dbFile)
os.Exit(1)
}
if !hasOut {
out = fmt.Sprintf("forgetting-migrate-%s.tar.gz", time.Now().Format("20060102-150405"))
}
fmt.Println()
fmt.Println(" Перенос панели на отдельный сервер. Сейчас:")
fmt.Println(" · панель и подписка sen:// здесь остановятся и больше не запустятся сами;")
fmt.Println(" · VPN продолжит работать: пиры и awg-ctrl не трогаются;")
fmt.Println(" · архив с базой панели и ключами подписки — " + bold(out))
if !c.confirmYes(" Продолжить?") {
return
}
c.stop("ui")
// Флаг — до снимка: перезапуск между ними поднял бы панель с базой, которая уже уехала.
content := updateEnvVar(c.readEnvFile(), "PANEL_MOVED", "on")
if err := c.writeEnvFile(content); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
if err := c.writeExport(out, dbFile); err != nil {
errorf(" Архив не создан: %v", err)
c.writeEnvFile(removeEnvVar(c.readEnvFile(), "PANEL_MOVED"))
os.Remove(out)
c.start("ui")
os.Exit(1)
}
fmt.Printf("\n %s Архив: %s %s\n", green("✓"), out, dim("(в нём ключ подписи — храните как пароль, после переноса удалите)"))
fmt.Println("\n Дальше:")
fmt.Println(" 1. Новая панель: install.sh, роль «только панель»")
fmt.Println(" 2. Скопируйте архив туда: " + dim("scp "+out+" root@<новая-панель>:"))
fmt.Println(" 3. На новой панели: " + bold("awg-ctrl migrate import "+filepath.Base(out)))
fmt.Println(" 4. Здесь — строку, которую она напечатает: " + bold("awg-ctrl join '<строка>'"))
fmt.Println(" " + dim("Передумали до шага 4: awg-ctrl migrate cancel"))
}
// svcList — сервисы роли без поправки на PANEL_MOVED и ретранслятор.
func (c *CLI) svcList() []string {
return without(without(c.all, "relay"), "ui")
}
func (c *CLI) writeExport(out, dbFile string) error {
db, err := migrate.OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
name, ip := "VPN", os.Getenv("SERVER_IP")
if h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health"); h != nil {
name = strOr(get(h, "server"), name)
if ip == "" {
ip = strOr(get(h, "ip"), "")
}
}
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
err = migrate.Write(f, db, c.paths(), exportMeta(c.version, name, ip, envPort("UI_PORT"), envPort("SUB_PORT"),
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT")))
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int) jsjson.Object {
return jsjson.Object{
{Key: "version", Val: version}, {Key: "created_at", Val: time.Now().Unix()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: uiPort}, {Key: "sub_port", Val: subPort}, {Key: "sub_tls", Val: subTLS}, {Key: "node_port", Val: nodePort},
}
}
// ── import (новая панель) ──────────────────────────────────────────────────
func (c *CLI) migrateImport(file string) {
if c.isNode {
errorf(" Это нода — импортировать панель нужно на core")
os.Exit(1)
}
if contains(c.svcList(), "awgctrl") {
errorf(" У этой панели свой VPN (LOCAL_NODE=on). Импорт — на панель без VPN: install.sh, роль «только панель»")
os.Exit(1)
}
f, err := os.Open(file)
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
b, err := migrate.Read(f)
f.Close()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer b.Close()
paths := c.paths()
if n, err := migrate.PanelData(paths["ui.db"]); err != nil || n > 0 {
errorf(" На этой панели уже есть данные (%s) — импорт их затёр бы. Нужна чистая панель.", paths["ui.db"])
os.Exit(1)
}
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
for host == "" || !reJoinHost.MatchString(host) {
host = strings.TrimSpace(c.ask(" Публичный адрес этой панели (IP или домен): "))
if host == "" {
return
}
}
plan, err := migrate.NewPlan(b, envPort, envPort("UI_PORT"), host)
if err != nil {
errorf(" %v (%s)", err, c.envFile)
os.Exit(1)
}
fmt.Println()
from := b.Str("server_ip")
if v := b.Str("version"); v != "" {
from += ", версия " + v
}
fmt.Printf(" Импорт панели «%s» (%s)\n", b.Str("server_name"), from)
fmt.Printf(" · подписка :%d, хаб нод :%d, адрес панели %s\n", plan.SubPort, plan.NodePort, host)
fmt.Println(" · прежние файлы панели уйдут в *.bak-<время>")
if !b.Has["sub_sign.key"] {
fmt.Println(" · " + dim("ключа подписи в архиве нет — на старом сервере подписка не была настроена"))
}
if !c.confirmYes(" Продолжить?") {
return
}
c.stop("ui")
secret := make([]byte, 16)
rand.Read(secret)
if err := migrate.Install(b, paths); err != nil {
errorf(" %v", err)
os.Exit(1)
}
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
errorf(" ui.db: %v", err)
os.Exit(1)
}
content := c.readEnvFile()
for _, kv := range plan.Env {
content = updateEnvVar(content, kv[0], kv[1])
}
if err := c.writeEnvFile(content); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
c.restartFresh()
// Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте.
var pin string
for range 50 {
if pin, err = migrate.CertPin(paths["node_tls.crt"]); err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
errorf(" Нет сертификата хаба %s — проверьте лог awg-ui и выдайте строку в панели (сервер #0)", paths["node_tls.crt"])
os.Exit(1)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
fmt.Printf("\n %s Панель перенесена. На старом сервере выполните (строка действует час):\n\n", green("✓"))
fmt.Println(" awg-ctrl join '" + join + "'")
fmt.Println()
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
}
// ── cancel (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateCancel() {
if !c.panelMoved() {
fmt.Println(" Переноса не было: панель на месте")
return
}
if c.isNode {
errorf(" Сервер уже нода новой панели — база там, и вернуть её сюда так нельзя.")
errorf(" Если всё же нужно: awg-ctrl unjoin, затем верните ui.db с новой панели вручную.")
os.Exit(1)
}
content := c.readEnvFile()
for _, k := range []string{"PANEL_MOVED", "RELAY"} {
content = removeEnvVar(content, k)
}
c.writeEnvFile(content)
fmt.Printf(" %s Перенос отменён, панель запускается здесь снова\n", green("✓"))
fmt.Println(" " + dim("Если архив уже импортирован на новую панель — не запускайте там ui, пока она не очищена"))
c.restartFresh()
}
// ── rehome (нода) ──────────────────────────────────────────────────────────
// rehome — панель переехала на новый адрес: тот же pin и id, другой CORE_HOST[:CORE_PORT].
func (c *CLI) rehome(addr string, hasAddr bool) {
if !c.isNode {
errorf(" Это не нода — менять адрес core нечему")
os.Exit(1)
}
host, port := addr, ""
if h, p, err := net.SplitHostPort(addr); err == nil {
host, port = h, p
}
if p, err := strconv.Atoi(port); !hasAddr || !reJoinHost.MatchString(host) || port != "" && (err != nil || p <= 0 || p > 65535) {
errorf(" Usage: awg-ctrl rehome <хост[:порт]> (адрес новой панели; pin и id ноды остаются)")
os.Exit(1)
}
content := updateEnvVar(c.readEnvFile(), "CORE_HOST", host)
if port != "" {
content = updateEnvVar(content, "CORE_PORT", port)
} else {
port = os.Getenv("CORE_PORT")
}
// Адрес, указанный руками, главнее того, что сообщила панель.
c.writeEnvFile(removeEnvVar(content, "CORE_ENDPOINT"))
fmt.Printf(" %s Core: %s:%s\n", green("✓"), host, port)
c.restartFresh()
}
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func decodeJoin(t *testing.T, s string) jsjson.Object {
t.Helper()
v, err := jsjson.Decode(fromB64url(strings.TrimPrefix(s, "awgjoin://")))
o, ok := v.(jsjson.Object)
if err != nil || !ok {
t.Fatalf("join-строка не разбирается: %v", err)
}
return o
}
// export берёт пути и порты из окружения сервисов, как их видит awg-ui.
func TestWriteExportFromEnv(t *testing.T) {
dir := t.TempDir()
sign := filepath.Join(dir, "sign")
os.WriteFile(sign, []byte("sign"), 0o600)
dbFile := filepath.Join(dir, "ui.db")
db, err := migrate.OpenDB(dbFile)
if err != nil {
t.Fatal(err)
}
db.Exec("CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
db.Exec("INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')")
db.Close()
for k, v := range map[string]string{
"AWG_ROOT": dir, "UI_DB_FILE": dbFile, "SUB_SIGN_KEY_FILE": sign, "AWGCTRL_PORT": "1", "SERVER_IP": "198.51.100.1",
"SUB_PORT": "40123", "SUB_TLS": "on", "NODE_PORT": "40500", "UI_PORT": "33000",
"SUB_TLS_KEY_FILE": filepath.Join(dir, "нет"), "SUB_TLS_CERT_FILE": filepath.Join(dir, "нет"),
"NODE_TLS_KEY_FILE": filepath.Join(dir, "нет"), "NODE_TLS_CERT_FILE": filepath.Join(dir, "нет"),
} {
t.Setenv(k, v)
}
out := filepath.Join(dir, "bundle.tar.gz")
if err := New().writeExport(out, dbFile); err != nil {
t.Fatal(err)
}
if st, _ := os.Stat(out); st.Mode().Perm() != 0o600 {
t.Errorf("права архива %v — в нём ключ подписи", st.Mode().Perm())
}
f, _ := os.Open(out)
b, err := migrate.Read(f)
f.Close()
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Str("server_ip") != "198.51.100.1" || b.Str("server_name") != "VPN" ||
b.Num("sub_port") != 40123 || b.Num("node_port") != 40500 || b.Num("ui_port") != 33000 || b.Meta.Get2("sub_tls") != true ||
!b.Has["sub_sign.key"] || b.Has["sub_tls.key"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
}
func TestRelayRoutes(t *testing.T) {
j := decodeJoin(t, migrate.JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123))
t.Setenv("SUB_PORT", "40123")
t.Setenv("NODE_PORT", "40400")
t.Setenv("UI_PORT", "32000")
if got, want := relayRoutes("203.0.113.7", 40500, j), "40123=203.0.113.7:40123,40400=203.0.113.7:40500,32000=203.0.113.7:33100"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
// Строка, выданная панелью заново (без ui/sub): подписка — на тот же порт, панель не пересылаем.
t.Setenv("NODE_PORT", "")
plain := decodeJoin(t, migrate.JoinString("2001:db8::7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 0, 0))
if got, want := relayRoutes("2001:db8::7", 40500, plain), "40123=[2001:db8::7]:40123"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
}
+265
View File
@@ -0,0 +1,265 @@
package cli
// `awg-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор» (порт
// cli/src/monitor.ts). Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не
// ставит, она только просит (через awg-ctrl) или показывает эту команду. Всё слушает
// 127.0.0.1: наружу Prometheus не торчит, а панель читает его по HTTP на той же машине.
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
const (
awgDir = "/etc/amnezia/amneziawg"
promDir = "/etc/prometheus"
promConf = promDir + "/prometheus.yml"
promToken = promDir + "/forgetting.token"
monitorHashFile = awgDir + "/monitor_token.sha256" // хэш локального токена: его читает awg-ui
promDefaults = "/etc/default/prometheus"
promMarker = "# managed by forgetting"
promListen = "127.0.0.1:9090"
promRetention = "31d" // с запасом на вид «30 дней» во вкладке «Монитор»
)
func mlog(s string) { fmt.Println(" " + s) }
func mfail(s string) {
fmt.Fprintf(os.Stderr, "\n ✗ %s\n\n", s)
os.Exit(1)
}
type shResult struct {
ok bool
output string
}
// sh — команда без stdin; quiet — вывод забираем себе, иначе он идёт в терминал.
func sh(quiet bool, name string, args ...string) shResult {
cmd := exec.Command(name, args...)
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
if quiet {
out, err := cmd.CombinedOutput()
return shResult{err == nil, string(out)}
}
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return shResult{cmd.Run() == nil, ""}
}
var (
reOSID = regexp.MustCompile(`(?m)^ID=(.*)$`)
reOSIDLike = regexp.MustCompile(`(?m)^ID_LIKE=(.*)$`)
reDebian = regexp.MustCompile(`(?i)debian|ubuntu`)
rePromVer = regexp.MustCompile(`version (\d+)\.(\d+)`)
reArgsLine = regexp.MustCompile(`(?m)^ARGS=.*$`)
)
func osFamily() string {
raw, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
val := func(re *regexp.Regexp) string {
if m := re.FindStringSubmatch(string(raw)); m != nil {
return strings.ReplaceAll(m[1], `"`, "")
}
return ""
}
return val(reOSID) + " " + val(reOSIDLike)
}
func promReady() bool {
cl := &http.Client{Timeout: 1500 * time.Millisecond, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get("http://" + promListen + "/-/ready")
if err != nil {
return false
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp.StatusCode == 200
}
func promInstalled() bool { return sh(true, "dpkg", "-s", "prometheus").ok }
func promActive() bool { return sh(true, "systemctl", "is-active", "--quiet", "prometheus").ok }
func promManaged() bool {
raw, err := os.ReadFile(promConf)
return err == nil && strings.Contains(string(raw), promMarker)
}
// authBlock — новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран.
func authBlock() string {
r := sh(true, "prometheus", "--version")
maj, min := 2, 0
if m := rePromVer.FindStringSubmatch(r.output); m != nil {
maj, _ = strconv.Atoi(m[1])
min, _ = strconv.Atoi(m[2])
}
if maj > 2 || maj == 2 && min >= 26 {
return " authorization:\n credentials_file: " + promToken + "\n"
}
return " bearer_token_file: " + promToken + "\n"
}
// backupStamp — new Date().toISOString() с «:» и «.» → «-».
func backupStamp() string {
return strings.NewReplacer(":", "-", ".", "-").Replace(time.Now().UTC().Format("2006-01-02T15:04:05.000Z"))
}
func copyFile(src, dst string) {
if raw, err := os.ReadFile(src); err == nil {
os.WriteFile(dst, raw, 0o644)
}
}
func writePromConfig(uiPort string) {
if exists(promConf) {
copyFile(promConf, promConf+".bak-"+backupStamp())
}
os.WriteFile(promConf, []byte(promMarker+`
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: forgetting
scheme: http
metrics_path: /metrics
`+authBlock()+` static_configs:
- targets: ['127.0.0.1:`+uiPort+`']
`), 0o644)
}
// writePromToken — токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш.
func writePromToken() {
b := make([]byte, 24)
rand.Read(b)
token := "awgm_" + b64url.EncodeToString(b)
os.WriteFile(promToken, []byte(token), 0o640)
sh(true, "chown", "root:prometheus", promToken)
sum := sha256.Sum256([]byte(token))
os.WriteFile(monitorHashFile, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
}
func setPromArgs() {
line := `ARGS="--web.listen-address=` + promListen + ` --storage.tsdb.retention.time=` + promRetention + `"`
raw, _ := os.ReadFile(promDefaults)
text := string(raw)
if text != "" {
copyFile(promDefaults, promDefaults+".bak-"+backupStamp())
}
if loc := reArgsLine.FindStringIndex(text); loc != nil {
text = text[:loc[0]] + line + text[loc[1]:]
} else {
text = strings.TrimSuffix(text, "\n") + "\n" + line + "\n"
}
os.WriteFile(promDefaults, []byte(text), 0o644)
}
func (c *CLI) monitorInstall() {
if os.Getuid() != 0 {
mfail("Нужен root: sudo awg-ctrl monitor install")
}
if !reDebian.MatchString(osFamily()) {
mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).")
}
uiPort := os.Getenv("UI_PORT")
if uiPort == "" {
mfail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.")
}
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
if promInstalled() && exists(promConf) && !promManaged() {
mfail("Prometheus уже установлен и настроен не нами (" + promConf + "). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.")
}
mlog("1/4 установка пакета prometheus")
if !promInstalled() {
if !sh(false, "apt-get", "update", "-qq").ok {
mfail("apt-get update завершился ошибкой")
}
if !sh(false, "apt-get", "install", "-y", "--no-install-recommends", "prometheus").ok {
mfail("apt-get install prometheus завершился ошибкой")
}
} else {
mlog("уже установлен")
}
mlog("2/4 токен и конфиг")
writePromToken()
writePromConfig(uiPort)
mlog("3/4 слушаем только " + promListen + ", хранение " + promRetention)
setPromArgs()
mlog("4/4 запуск")
sh(true, "systemctl", "enable", "prometheus")
if !sh(false, "systemctl", "restart", "prometheus").ok {
mfail("prometheus не запустился: journalctl -u prometheus")
}
for range 30 {
if promReady() {
fmt.Printf("\n ✓ Prometheus работает на %s, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n\n", promListen)
return
}
time.Sleep(time.Second)
}
mfail("Prometheus не ответил за 30 с: journalctl -u prometheus")
}
func yesNo(b bool, yes, no string) string {
if b {
return yes
}
return no
}
func monitorStatus() {
ok := promReady()
fmt.Println(" пакет: " + yesNo(promInstalled(), "установлен", "нет"))
fmt.Println(" сервис: " + yesNo(promActive(), "работает", "остановлен"))
fmt.Println(" наш конфиг: " + yesNo(promManaged(), "да", "нет"))
fmt.Println(" готов: " + yesNo(ok, "да, "+promListen, "нет"))
}
func monitorRemove() {
if os.Getuid() != 0 {
mfail("Нужен root: sudo awg-ctrl monitor remove")
}
// Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили.
if promInstalled() && exists(promConf) && !promManaged() {
mfail("Prometheus настроен не нами (" + promConf + "), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus")
}
mlog("1/3 остановка сервиса")
sh(true, "systemctl", "disable", "--now", "prometheus")
mlog("2/3 токен")
os.Remove(promToken)
os.Remove(monitorHashFile)
mlog("3/3 удаление пакета и накопленных данных")
if promInstalled() && !sh(false, "apt-get", "purge", "-y", "prometheus").ok {
mfail("apt-get purge prometheus завершился ошибкой")
}
fmt.Print("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n\n")
}
func (c *CLI) monitorCmd(sub string) {
switch sub {
case "install":
c.monitorInstall()
case "status":
monitorStatus()
case "remove":
monitorRemove()
default:
fmt.Println("Usage: awg-ctrl monitor <install|status|remove>")
os.Exit(1)
}
}
+455
View File
@@ -0,0 +1,455 @@
package ctrl
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode"
)
// ───────── запуск команд ─────────
type cmdResult struct {
Stdout, Stderr string
Code int // -1: не запустилась
}
func (r cmdResult) ok() bool { return r.Code == 0 }
func runCmdIn(stdin string, name string, args ...string) cmdResult {
cmd := exec.Command(name, args...)
var so, se bytes.Buffer
cmd.Stdout, cmd.Stderr = &so, &se
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
err := cmd.Run()
res := cmdResult{Stdout: so.String(), Stderr: se.String()}
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
res.Code = ee.ExitCode()
} else {
res.Code = -1
}
}
return res
}
func runCmd(name string, args ...string) cmdResult { return runCmdIn("", name, args...) }
// runShell — аналог execSync(cmd).trim(): через sh, ошибка при ненулевом коде.
func runShell(cmd string) (string, error) {
r := runCmdIn("", "/bin/sh", "-c", cmd)
if !r.ok() {
return "", fmt.Errorf("Command failed: %s\n%s", cmd, r.Stderr)
}
return strings.TrimSpace(r.Stdout), nil
}
func runCtx(timeout time.Duration, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var so, se bytes.Buffer
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout, cmd.Stderr = &so, &se
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(se.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), msg)
}
return so.String(), nil
}
// jsNumber — Number(s): пустая строка = 0, нечисло = NaN.
func jsNumber(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return math.NaN()
}
return f
}
// jsonStamp — new Date().toISOString().replace(/[:.]/g, "-").
func jsonStamp(t time.Time) string {
t = t.UTC()
return t.Format("2006-01-02T15-04-05") + fmt.Sprintf("-%03dZ", t.Nanosecond()/1e6)
}
// ───────── ключи ─────────
func (s *Server) generateKeys() (Keys, error) {
priv, err := runShell("umask 077 && awg genkey")
if err != nil {
return Keys{}, err
}
r := runCmdIn(priv, "awg", "pubkey")
if !r.ok() {
return Keys{}, errors.New("awg pubkey завершился с ошибкой")
}
psk, err := runShell("awg genpsk")
if err != nil {
return Keys{}, err
}
return Keys{Private: priv, Public: strings.TrimSpace(r.Stdout), PSK: psk}, nil
}
func (s *Server) genPSK() (string, error) { return runShell("awg genpsk") }
func (s *Server) getServerPublicKey() (string, error) {
b, err := os.ReadFile(filepath.Join(s.confDir, "server_public.key"))
if err != nil {
return "", errors.New("server_public.key не найден")
}
return strings.TrimSpace(string(b)), nil
}
// ───────── интерфейс ─────────
type peerStat struct {
Online bool
LastHandshake float64
Rx, Tx float64
}
// getPeersData: `awg show awg1 dump`, колонки [0] pub, [4] handshake, [5] rx, [6] tx.
func (s *Server) getPeersData() map[string]peerStat {
out, err := runShell("awg show " + s.iface + " dump")
if err != nil {
s.log.Warn("getPeersData failed", "error", err)
return map[string]peerStat{}
}
res := map[string]peerStat{}
now := float64(time.Now().Unix())
lines := strings.Split(out, "\n")
for i := 1; i < len(lines); i++ {
parts := strings.Split(lines[i], "\t")
pub := parts[0]
if pub == "" {
continue
}
get := func(n int) float64 {
if n < len(parts) {
return jsNumber(parts[n])
}
return math.NaN() // Number(undefined)
}
hs := get(4)
res[pub] = peerStat{
Online: hs > 0 && (now-hs) < 180,
LastHandshake: hs, Rx: get(5), Tx: get(6),
}
}
return res
}
type ifStatus struct {
Up bool `json:"up"`
Peers int `json:"peers"`
PublicKey *string `json:"publicKey"`
}
var (
rePeerLine = regexp.MustCompile(`(?m)^peer:`)
rePubKey = regexp.MustCompile(`public key:\s*(.+)`)
)
func (s *Server) interfaceStatus() ifStatus {
r := runCmd("awg", "show", s.iface)
if !r.ok() {
return ifStatus{}
}
st := ifStatus{Up: true, Peers: len(rePeerLine.FindAllString(r.Stdout, -1))}
if m := rePubKey.FindStringSubmatch(r.Stdout); m != nil {
v := strings.TrimSpace(m[1])
st.PublicKey = &v
}
return st
}
func (s *Server) ensureInterfaceUp() error {
if !s.interfaceStatus().Up {
return fmt.Errorf("Interface %s is not up. Run: awg-quick up %s", s.iface, s.iface)
}
return nil
}
// readVersions: версия загруженного модуля (важнее версии на диске: интерфейс обслуживает
// именно загруженный) и awg-tools. Вызывается на старте и при restartAwg — только тогда
// модуль может смениться; из /health не вызывается (он без авторизации).
func readVersions() (module, tools string) {
if b, err := os.ReadFile("/sys/module/amneziawg/version"); err == nil {
module = strings.TrimSpace(string(b))
} else if v, err := runShell("modinfo -F version amneziawg"); err == nil {
module = v
}
if v, err := runShell("awg --version"); err == nil {
for _, t := range strings.Fields(v) {
if regexp.MustCompile(`^v?\d`).MatchString(t) {
tools = t
break
}
}
}
return
}
// ───────── awg1.conf ─────────
func (s *Server) confPath() string { return filepath.Join(s.confDir, s.iface+".conf") }
func trimEndJS(s string) string { return strings.TrimRightFunc(s, unicode.IsSpace) }
// rebuildConf пересобирает [Peer]-секции из БД; [Interface] сохраняется как есть.
// Пиры устройств идут после users; без записей в peers вывод байт-в-байт прежний.
func (s *Server) rebuildConf() error {
users, err := s.db.Query("SELECT name, ip, pub_key, psk_key FROM users")
if err != nil {
return err
}
type pr struct{ label, ip, pub, psk string }
var all []pr
for users.Next() {
var n, ip, pub, psk string
if err := users.Scan(&n, &ip, &pub, &psk); err != nil {
users.Close()
return err
}
all = append(all, pr{n, ip, pub, psk})
}
users.Close()
devices, err := s.peersAll()
if err != nil {
return err
}
if _, err := os.Stat(s.confPath()); err != nil {
return nil
}
raw, err := os.ReadFile(s.confPath())
if err != nil {
return err
}
for _, d := range devices {
label := d.Owner
if label == "" {
label = "device"
}
all = append(all, pr{label, d.IP, d.PubKey, d.PskKey})
}
iface := trimEndJS(pPeerSplit.Split(string(raw), 2)[0])
ka := s.cfg().Keepalive
blocks := make([]string, 0, len(all))
for _, u := range all {
blocks = append(blocks, "\n# "+u.label+"\n[Peer]\nPublicKey = "+u.pub+"\nPresharedKey = "+u.psk+
"\nAllowedIPs = "+u.ip+"/32\nPersistentKeepalive = "+ka)
}
if err := os.WriteFile(s.confPath(), []byte(iface+"\n"+strings.Join(blocks, "\n")+"\n"), 0o600); err != nil {
return err
}
s.log.Info("conf rebuilt", "peers", len(all))
return nil
}
var reAwgQuickOnly = regexp.MustCompile(`(?i)^\s*(Address|DNS|MTU|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)\s*=`)
var rePrivateKeyLine = regexp.MustCompile(`^\s*PrivateKey\s*=`)
var reInterfaceLine = regexp.MustCompile(`^\s*\[Interface\]`)
// buildSyncConf готовит «stripped»-конфиг для `awg syncconf`: без awg-quick-ключей, но
// с [Interface] и PrivateKey.
//
// 🛑 КРИТИЧНО: [Interface] с PrivateKey ОБЯЗАН попасть в этот конфиг. Когда syncconf
// получал только [Peer]-блоки, AmneziaWG обнулял приватный ключ интерфейса и параметры
// обфускации — сервер поднимался с public-key=(none), и ВСЕ клиенты отваливались.
// PrivateKey берём из server_private.key — та же идентичность, что в server_public.key
// (его зашивают в vpn:// ключи) и в PostUp.
func (s *Server) buildSyncConf() (string, error) {
privRaw, err := os.ReadFile(s.serverPrivKeyFile)
if err != nil {
return "", err
}
priv := strings.TrimSpace(string(privRaw))
raw, err := os.ReadFile(s.confPath())
if err != nil {
return "", err
}
var out []string
replaced := false
for _, line := range strings.Split(string(raw), "\n") {
if reAwgQuickOnly.MatchString(line) {
continue
}
if rePrivateKeyLine.MatchString(line) {
out = append(out, "PrivateKey = "+priv)
replaced = true
continue
}
out = append(out, line)
}
if !replaced {
for i, l := range out {
if reInterfaceLine.MatchString(l) {
out = append(out[:i+1], append([]string{"PrivateKey = " + priv}, out[i+1:]...)...)
break
}
}
}
return strings.Join(out, "\n"), nil
}
func (s *Server) syncPeers() error {
if err := s.rebuildConf(); err != nil {
return err
}
cfg, err := s.buildSyncConf()
if err != nil {
return err
}
f, err := os.CreateTemp("", "awg_sync_*.conf") // 0600, O_EXCL, случайное имя
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.WriteString(cfg); err != nil {
f.Close()
return err
}
f.Close()
if r := runCmd("awg", "syncconf", s.iface, f.Name()); !r.ok() {
s.log.Warn("syncPeers syncconf failed", "stderr", r.Stderr)
}
var n int
s.db.QueryRow("SELECT COUNT(*) AS n FROM users").Scan(&n)
s.log.Info("peers synced", "count", n)
return nil
}
// reloadAwgParams перечитывает параметры после любой правки конфига: иначе ключи
// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1.
func (s *Server) reloadAwgParams() string {
p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
s.cfgMu.Lock()
s.c.Params = p
s.c.Gen = p.Gen()
s.c.Keepalive = keepaliveByGen[s.c.Gen]
g := s.c.Gen
s.cfgMu.Unlock()
s.log.Info("awg params loaded from conf", "Jc", p.Jc, "H1", p.H1, "gen", g)
return g
}
func (s *Server) restartAwg() error {
s.log.Info("AWG restart: down")
if r := runCmd("awg-quick", "down", s.iface); !r.ok() {
s.log.Warn("awg-quick down failed", "stderr", r.Stderr)
}
s.log.Info("AWG restart: up")
if r := runCmd("awg-quick", "up", s.iface); !r.ok() {
return fmt.Errorf("awg-quick up failed: %s", r.Stderr)
}
// Конфиг мог измениться, пока интерфейс лежал (переход на 3.1, ручная правка) —
// перечитываем ДО syncPeers: keepalive из CONFIG уходит в [Peer]-блоки.
gen := s.reloadAwgParams()
if err := s.syncPeers(); err != nil {
return err
}
go s.shaping.Apply() // tc-правила жили на интерфейсе, который мы только что пересоздали
m, t := readVersions()
s.cfgMu.Lock()
s.c.AwgModule, s.c.AwgTools = m, t
s.cfgMu.Unlock()
s.log.Info("AWG restart: done", "module", m, "tools", t, "gen", gen)
return nil
}
func (s *Server) startInterface() (ifStatus, error) {
st := s.interfaceStatus()
if st.Up {
s.log.Info("AWG already up", "peers", st.Peers)
return st, nil
}
if r := runCmd("awg-quick", "up", s.iface); !r.ok() {
return ifStatus{}, fmt.Errorf("awg-quick up failed: %s", r.Stderr)
}
if err := s.syncPeers(); err != nil {
return ifStatus{}, err
}
go s.shaping.Apply()
return s.interfaceStatus(), nil
}
// setPeer: PSK уходит во временный файл, а не в аргументы — иначе он виден в /proc/<pid>/cmdline.
func (s *Server) setPeer(pub, psk, ip string) cmdResult {
f, err := os.CreateTemp("", "awg_psk_*.tmp")
if err != nil {
return cmdResult{Stderr: err.Error(), Code: -1}
}
defer os.Remove(f.Name())
f.WriteString(psk)
f.Close()
return runCmd("awg", "set", s.iface, "peer", pub,
"preshared-key", f.Name(),
"allowed-ips", ip+"/32",
"persistent-keepalive", s.cfg().Keepalive)
}
// kernelPeers — ключи пиров, стоящих в ядре (nil — не удалось спросить).
func (s *Server) kernelPeers() map[string]bool {
r := runCmd("awg", "show", s.iface, "peers")
if !r.ok() {
return nil
}
set := map[string]bool{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l != "" {
set[l] = true
}
}
return set
}
// dropFromKernel снимает пира с интерфейса и убеждается, что его там нет. Порядок во
// всех вызывающих: сначала ядро, потом база — при отказе строка остаётся, и повторная
// попытка (надгробие панели, следующий sync) найдёт пира.
func (s *Server) dropFromKernel(pub string) bool {
r := runCmd("awg", "set", s.iface, "peer", pub, "remove")
if r.ok() {
return true
}
if left := s.kernelPeers(); left != nil && !left[pub] {
return true // ответ странный, но пира уже нет
}
s.log.Error("peer remove from kernel failed", "pub", pub, "stderr", r.Stderr)
return false
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
func b64Std(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
func stdB64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
// lenientB64URL повторяет Buffer.from(s, "base64url") в Node: терпит паддинг, «+», «/».
func lenientB64URL(s string) ([]byte, error) {
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
return base64.RawURLEncoding.DecodeString(s)
}
+233
View File
@@ -0,0 +1,233 @@
package ctrl
import (
"database/sql"
"fmt"
"net/url"
_ "modernc.org/sqlite"
)
// Схема users.db — та же, что у Node-версии: существующие базы открываются как есть.
// PRAGMA не задаём (журнал по умолчанию, без WAL); ждём блокировку 5 с, как better-sqlite3.
type userRow struct {
Name string `json:"name"`
IP string `json:"ip"`
PubKey string `json:"pub_key"`
VpnKey string `json:"vpn_key"`
PskKey string `json:"psk_key"`
KeyGen string `json:"key_gen"`
VpnKeyPrev string `json:"vpn_key_prev"`
}
type peerRow struct {
PubKey string
IP string
PskKey string
Owner string
CreatedAt int64
}
func openDB(path string) (*sql.DB, error) {
dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + "?_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// Один соединение: SQLite всё равно пишет по одному, а так нет «database is locked» между
// горутинами и Tx не конкурирует с запросами вне транзакции.
db.SetMaxOpenConns(1)
if err := initSchema(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func initSchema(db *sql.DB) error {
for _, q := range []string{
`CREATE TABLE IF NOT EXISTS users (
name TEXT PRIMARY KEY,
ip TEXT NOT NULL UNIQUE,
pub_key TEXT NOT NULL,
vpn_key TEXT NOT NULL,
psk_key TEXT NOT NULL DEFAULT ''
)`,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)",
} {
if _, err := db.Exec(q); err != nil {
return err
}
}
// ALTER TABLE идемпотентным не бывает — смотрим фактический список колонок.
cols := map[string]bool{}
rows, err := db.Query("PRAGMA table_info(users)")
if err != nil {
return err
}
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
cols[name] = true
}
rows.Close()
if !cols["key_gen"] {
if _, err := db.Exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'"); err != nil {
return err
}
}
if !cols["vpn_key_prev"] {
if _, err := db.Exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
}
for _, q := range []string{
`CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
// Пиры устройств sen://: приватного ключа здесь нет и быть не может.
`CREATE TABLE IF NOT EXISTS peers (
pub_key TEXT PRIMARY KEY,
ip TEXT NOT NULL UNIQUE,
psk_key TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`,
} {
if _, err := db.Exec(q); err != nil {
return err
}
}
return nil
}
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)
}
func (s *Server) getCfg(key, fallback string) string {
var v string
if err := s.db.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&v); err != nil {
return fallback
}
return v
}
func (s *Server) setCfg(key, value string) {
if _, err := s.db.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, value); err != nil {
s.log.Error("setCfg failed", "key", key, "error", err)
}
}
const userCols = "name, ip, pub_key, vpn_key, psk_key, key_gen, vpn_key_prev"
func scanUsers(rows *sql.Rows) ([]userRow, error) {
defer rows.Close()
out := []userRow{}
for rows.Next() {
var u userRow
if err := rows.Scan(&u.Name, &u.IP, &u.PubKey, &u.VpnKey, &u.PskKey, &u.KeyGen, &u.VpnKeyPrev); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *Server) usersAll() ([]userRow, error) {
rows, err := s.db.Query("SELECT " + userCols + " FROM users")
if err != nil {
return nil, err
}
return scanUsers(rows)
}
func (s *Server) userGet(name string) (*userRow, error) {
rows, err := s.db.Query("SELECT "+userCols+" FROM users WHERE name = ?", name)
if err != nil {
return nil, err
}
us, err := scanUsers(rows)
if err != nil || len(us) == 0 {
return nil, err
}
return &us[0], nil
}
func scanPeers(rows *sql.Rows) ([]peerRow, error) {
defer rows.Close()
out := []peerRow{}
for rows.Next() {
var p peerRow
if err := rows.Scan(&p.PubKey, &p.IP, &p.PskKey, &p.Owner, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Server) peersAll() ([]peerRow, error) {
rows, err := s.db.Query("SELECT pub_key, ip, psk_key, owner, created_at FROM peers ORDER BY created_at, rowid")
if err != nil {
return nil, err
}
return scanPeers(rows)
}
func (s *Server) peerGet(pub string) (*peerRow, error) {
rows, err := s.db.Query("SELECT pub_key, ip, psk_key, owner, created_at FROM peers WHERE pub_key = ?", pub)
if err != nil {
return nil, err
}
ps, err := scanPeers(rows)
if err != nil || len(ps) == 0 {
return nil, err
}
return &ps[0], nil
}
func (s *Server) userPubTaken(q queryer, pub string) (bool, error) {
var n int
if err := q.QueryRow("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?", pub).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// nextIp: первый свободный 10.9.c.d (c = 0..255, d = 2..254) среди users и peers.
func (s *Server) nextIp(q queryer, subnet string) (string, error) {
rows, err := q.Query("SELECT ip FROM users UNION SELECT ip FROM peers")
if err != nil {
return "", err
}
used := map[string]bool{}
for rows.Next() {
var ip string
if err := rows.Scan(&ip); err != nil {
rows.Close()
return "", err
}
used[ip] = true
}
rows.Close()
for c := 0; c <= 255; c++ {
for d := 2; d <= 254; d++ {
ip := fmt.Sprintf("%s.%d.%d", subnet, c, d)
if !used[ip] {
return ip, nil
}
}
}
return "", fmt.Errorf("Подсеть заполнена")
}
+118
View File
@@ -0,0 +1,118 @@
package ctrl
import (
"math"
"regexp"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
var reOwner = regexp.MustCompile(`^[\w/.-]{0,64}$`)
// checkPubKeyAny — checkPubKey для значения из JSON (не строка → тот же 400).
func checkPubKeyAny(v any) (string, error) {
k, ok := v.(string)
if !ok {
return "", httpErr(400, "Неверный публичный ключ")
}
return checkPubKey(k)
}
func checkPubKeys(arr []any) ([]string, error) {
out := make([]string, 0, len(arr))
for _, v := range arr {
k, err := checkPubKeyAny(v)
if err != nil {
return nil, err
}
out = append(out, k)
}
return out, nil
}
// jsToNumber — Number(v) для значений из JSON.
func jsToNumber(v any) float64 {
switch x := v.(type) {
case nil:
return 0
case float64:
return x
case bool:
if x {
return 1
}
return 0
case string:
return jsNumber(x)
}
return math.NaN()
}
func (s *Server) knownPubKeys() (map[string]bool, error) {
known := map[string]bool{}
rows, err := s.db.Query("SELECT pub_key FROM users")
if err != nil {
return nil, err
}
for rows.Next() {
var k string
rows.Scan(&k)
known[k] = true
}
rows.Close()
peers, err := s.peersAll()
if err != nil {
return nil, err
}
for _, p := range peers {
known[p.PubKey] = true
}
return known, nil
}
func (s *Server) isKnownPub(k string) (bool, error) {
if p, err := s.peerGet(k); err != nil || p != nil {
return p != nil, err
}
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE pub_key = ?", k).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// profileAwg — параметры для /api/profile в порядке CONFIG.awgParams (порядок объявления в
// DEFAULT_AWG_PARAMS), без пустых значений; числа — строками.
func profileAwg(p Params) jsjson.Object {
o := jsjson.Object{}
add := func(k, v string) {
if v != "" {
o = append(o, jsjson.KV{Key: k, Val: v})
}
}
itoa := strconv.Itoa
add("Jc", itoa(p.Jc))
add("Jmin", itoa(p.Jmin))
add("Jmax", itoa(p.Jmax))
add("S1", itoa(p.S1))
add("S2", itoa(p.S2))
add("S3", itoa(p.S3))
add("S4", itoa(p.S4))
add("H1", p.H1)
add("H2", p.H2)
add("H3", p.H3)
add("H4", p.H4)
add("I1", p.I1)
add("I2", p.I2)
add("I3", p.I3)
add("I4", p.I4)
add("I5", p.I5)
for _, k := range AWG3Keys {
add(k, p.A3[k])
}
return o
}
var _ = strings.TrimSpace
+236
View File
@@ -0,0 +1,236 @@
package ctrl
import (
"fmt"
"io"
"mime"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Минимальный роутер с поведением Express 4, на которое опираются клиенты:
// - тело JSON разбирается ДО авторизации и маршрутизации (битый JSON без токена → 400, не 401);
// - ошибки разбора и неизвестные пути отвечают HTML, как finalhandler Express;
// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется.
// Первое совпавшее объявление выигрывает — порядок ниже важен («reissue» до «:name», «sync» до «:pub»).
type rctx struct {
w http.ResponseWriter
r *http.Request
s *Server
params map[string]string
body any // jsjson.Object, []any или nil (= {})
}
type result struct {
status int
v any
}
type route struct {
method string
segs []string
mw []func(*rctx) error
lock bool
h func(*rctx) (result, error)
}
const (
bodyLimitSmall = 1024
bodyLimitBig = 512 * 1024
)
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.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", text)
}
// readBody — express.json({limit}): только application/json, объект или массив.
func readBody(r *http.Request, limit int64) (any, int, string) {
mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mt != "application/json" || r.ContentLength == 0 && r.Body == http.NoBody {
return nil, 0, ""
}
if r.ContentLength > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
if err != nil {
return nil, 400, "BadRequestError: request aborted"
}
if int64(len(raw)) > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace)
if trimmed == "" {
return nil, 0, ""
}
if trimmed[0] != '{' && trimmed[0] != '[' {
return nil, 400, "SyntaxError: Unexpected token in JSON"
}
v, err := jsjson.Decode(raw)
if err != nil {
return nil, 400, "SyntaxError: " + err.Error()
}
return v, 0, ""
}
func (c *rctx) obj() jsjson.Object {
o, _ := c.body.(jsjson.Object)
return o
}
func match(rt route, method string, segs []string) map[string]string {
if rt.method != method || len(rt.segs) != len(segs) {
return nil
}
params := map[string]string{}
for i, p := range rt.segs {
if strings.HasPrefix(p, ":") {
v, ok := unescape(segs[i])
if !ok {
return nil
}
params[p[1:]] = v
} else if !strings.EqualFold(p, segs[i]) {
return nil
}
}
return params
}
func unescape(s string) (string, bool) {
if !strings.Contains(s, "%") {
return s, true
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+2 < len(s)+0 && isHex(s[i+1]) && isHex(s[i+2]) {
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
} else if s[i] == '%' {
return "", false
} else {
b.WriteByte(s[i])
}
}
return b.String(), true
}
func isHex(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
func unhex(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
}
return c - 'A' + 10
}
func (s *Server) handler() http.Handler {
routes := s.routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Тело разбирается первым и для всех путей; лимит выбирает точный путь.
limit := int64(bodyLimitSmall)
p := strings.TrimSuffix(r.URL.EscapedPath(), "/")
if strings.EqualFold(p, "/api/peers/sync") || strings.EqualFold(p, "/api/audit/clean") {
limit = bodyLimitBig
}
body, status, msg := readBody(r, limit)
if status != 0 {
writeHTMLError(w, status, msg)
return
}
segs := strings.Split(strings.Trim(r.URL.EscapedPath(), "/"), "/")
for _, rt := range routes {
params := match(rt, r.Method, segs)
if params == nil {
continue
}
c := &rctx{w: w, r: r, s: s, params: params, body: body}
s.serve(c, rt)
return
}
writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.Path)
})
}
func (s *Server) serve(c *rctx, rt route) {
defer func() {
if p := recover(); p != nil {
s.log.Error("handler panic", "error", fmt.Sprint(p))
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
}()
for _, mw := range rt.mw {
if err := mw(c); err != nil {
s.fail(c, err)
return
}
}
if rt.lock {
s.mu.Lock()
defer s.mu.Unlock()
}
res, err := rt.h(c)
if err != nil {
s.fail(c, err)
return
}
writeJSON(c.w, res.status, res.v)
}
func (s *Server) fail(c *rctx, err error) {
if he, ok := err.(*HTTPError); ok {
writeJSON(c.w, he.Status, jsjson.Object{{Key: "error", Val: he.Msg}})
return
}
s.log.Error("handler error", "error", err)
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
// ───────── middleware ─────────
func (s *Server) auth(c *rctx) error {
h := c.r.Header.Get("Authorization")
tok := ""
if strings.HasPrefix(h, "Bearer ") {
tok = h[7:]
}
if tok == "" || !iauth.Verify(s.pub, tok, nowFn()) {
return httpErr(401, "Неверная авторизация")
}
return nil
}
var reName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`)
func validateName(c *rctx) error {
name, ok := c.params["name"]
if !ok {
name, _ = c.obj().Get2("name").(string)
}
if name == "" || !reName.MatchString(name) {
return httpErr(400, "Имя: буквы, цифры, _ и -, до 32 символов")
}
return nil
}
+189
View File
@@ -0,0 +1,189 @@
package ctrl
import (
"os"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с —
// несколько скрейперов не должны гонять `awg show` чаще.
const metricsTTL = 5 * time.Second
type metricsCache struct {
mu sync.Mutex
at time.Time
data any
}
type metricsPeer struct {
Kind string `json:"kind"`
Name string `json:"name"`
IP string `json:"ip"`
Online bool `json:"online"`
Rx float64 `json:"rx"`
Tx float64 `json:"tx"`
Priority string `json:"priority"`
Torrent bool `json:"torrent"`
}
type metricsHost struct {
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"`
} `json:"mem"`
}
type linkStats struct {
RxBytes float64 `json:"rx_bytes"`
TxBytes float64 `json:"tx_bytes"`
RxPackets float64 `json:"rx_packets"`
TxPackets float64 `json:"tx_packets"`
RxDropped float64 `json:"rx_dropped"`
TxDropped float64 `json:"tx_dropped"`
RxErrors float64 `json:"rx_errors"`
TxErrors float64 `json:"tx_errors"`
}
type metricsBody struct {
Up bool `json:"up"`
Gen string `json:"gen"`
Peers struct {
Total int `json:"total"`
Online int `json:"online"`
} `json:"peers"`
RxBytes float64 `json:"rx_bytes"`
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"`
}
func (s *Server) linkCounter(name string) float64 {
b, err := os.ReadFile("/sys/class/net/" + s.iface + "/statistics/" + name)
if err != nil {
return 0
}
return orZero(jsNumber(string(b)))
}
var cpuModes = []string{"user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"}
// readHost: нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у
// node_exporter): долю считает Prometheus через rate(). Память — MemAvailable, а не MemFree:
// кэш ядра свободен.
func readHost() *metricsHost {
stat, err := os.ReadFile("/proc/stat")
if err != nil {
return nil
}
first := strings.Fields(strings.SplitN(string(stat), "\n", 2)[0])
if len(first) < 2 {
return nil
}
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 = append(h.CPU, jsjson.KV{Key: m, Val: v / 100}) // USER_HZ = 100 на всех обычных ядрах
}
mem, err := os.ReadFile("/proc/meminfo")
if err != nil {
return nil
}
kb := func(k string) float64 {
m := regexp.MustCompile(`(?m)^` + k + `:\s+(\d+) kB`).FindStringSubmatch(string(mem))
if m == nil {
return 0
}
f, _ := strconv.ParseFloat(m[1], 64)
return f * 1024
}
h.Mem.Total, h.Mem.Available = kb("MemTotal"), kb("MemAvailable")
if la, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(la))
for i := 0; i < 3 && i < len(f); i++ {
h.Load = append(h.Load, orZero(jsNumber(f[i])))
}
}
return h
}
func (s *Server) collectMetrics() (*metricsBody, error) {
st := s.interfaceStatus()
stats := map[string]peerStat{}
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
}
b := &metricsBody{Up: st.Up, Gen: s.cfg().Gen, List: []metricsPeer{}}
for rows.Next() {
var n, ip, pub string
if err := rows.Scan(&n, &ip, &pub); err != nil {
rows.Close()
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), torrent[ip]})
}
rows.Close()
devs, err := s.peersAll()
if err != nil {
return nil, err
}
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), torrent[p.IP]})
}
b.Peers.Total = st.Peers
for _, ps := range stats {
if ps.Online {
b.Peers.Online++
}
b.RxBytes += orZero(ps.Rx)
b.TxBytes += orZero(ps.Tx)
}
b.Link = linkStats{
s.linkCounter("rx_bytes"), s.linkCounter("tx_bytes"), s.linkCounter("rx_packets"), s.linkCounter("tx_packets"),
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
}
// metricsCached отдаёт кэш на metricsTTL; ошибку не кэшируем.
func (s *Server) metricsCached() (any, error) {
m := &s.metrics
m.mu.Lock()
defer m.mu.Unlock()
if m.data != nil && time.Since(m.at) <= metricsTTL {
return m.data, nil
}
d, err := s.collectMetrics()
if err != nil {
m.data = nil
return nil, err
}
m.at, m.data = time.Now(), d
return d, 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 завис: запрос в базу изнутри цикла по строкам")
}
}
+104
View File
@@ -0,0 +1,104 @@
package ctrl
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
)
// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие
// одно из двух и без параметров: awg-ctrl запускает ту же команду, что оператор набрал бы
// руками (`awg-ctrl monitor install|remove`, root), так что код один. Панель сама пакетов
// не ставит и не удаляет — она только просит об этом здесь.
type monitorJob struct {
mu sync.Mutex
active bool
action string
exit *int
}
var reANSI = regexp.MustCompile("\x1b\\[[0-9;]*[A-Za-z]")
func (s *Server) logTail() string {
b, err := os.ReadFile(s.cliLog)
if err != nil {
return ""
}
t := string(b)
if len(t) > 4000 { // последние 4000 символов (в JS — UTF-16 единиц; для лога разницы нет)
t = t[len(t)-4000:]
}
return strings.ReplaceAll(reANSI.ReplaceAllString(t, ""), "\r", "\n")
}
// startMonitorJob запускает CLI в фоне; возвращает 409, если уже идёт.
func (s *Server) startMonitorJob(action string) error {
j := &s.monitor
j.mu.Lock()
if j.active {
j.mu.Unlock()
return httpErr(409, "Операция уже идёт")
}
if _, err := os.Stat(s.cliPath); err != nil {
j.mu.Unlock()
return httpErr(501, "CLI не найден ("+s.cliPath+"): выполните на сервере awg-ctrl monitor "+action)
}
os.MkdirAll(filepath.Dir(s.cliLog), 0o755)
f, err := os.OpenFile(s.cliLog, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
j.mu.Unlock()
return err
}
cmd := exec.Command(s.cliPath, "monitor", action)
cmd.Stdout, cmd.Stderr = f, f
j.active, j.action, j.exit = true, action, nil
if err := cmd.Start(); err != nil {
s.log.Error("monitor: не запустился", "action", action, "error", err.Error())
code := 127
j.active, j.exit = false, &code
f.Close()
j.mu.Unlock()
return nil
}
j.mu.Unlock()
go func() {
err := cmd.Wait()
code := 0
if err != nil {
code = 1
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
}
}
f.Close()
s.log.Info("monitor: завершён", "action", action, "code", code)
j.mu.Lock()
j.active, j.exit = false, &code
j.mu.Unlock()
}()
return nil
}
type jobView struct {
Action *string `json:"action"`
Running bool `json:"running"`
Exit *int `json:"exit"`
Log string `json:"log"`
}
func (s *Server) monitorView() jobView {
j := &s.monitor
j.mu.Lock()
defer j.mu.Unlock()
v := jobView{Running: j.active, Exit: j.exit}
if j.action != "" {
a := j.action
v.Action = &a
v.Log = s.logTail()
}
return v
}
+605
View File
@@ -0,0 +1,605 @@
package ctrl
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
// HTTPError — ошибка с кодом ответа; любая другая ошибка превращается в 500.
type HTTPError struct {
Status int
Msg string
}
func (e *HTTPError) Error() string { return e.Msg }
func httpErr(status int, msg string) error { return &HTTPError{status, msg} }
// ───────── пользователи (vpn://) ─────────
func (s *Server) addUser(name string) (*userRow, error) {
keys, err := s.generateKeys()
if err != nil {
return nil, err
}
serverPub, err := s.getServerPublicKey()
if err != nil {
return nil, err
}
cfg := s.cfg()
var ip string
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
ip, err = s.nextIp(tx, cfg.Subnet)
if err == nil {
_, err = tx.Exec("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)",
name, ip, keys.Public, "", keys.PSK, cfg.Gen)
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
vpnKey, err := EncodeVpnKey(s.keyEnv(), keys, ip, serverPub)
if err != nil {
return nil, err
}
if _, err := s.db.Exec("UPDATE users SET vpn_key = ? WHERE name = ?", vpnKey, name); err != nil {
return nil, err
}
if r := s.setPeer(keys.Public, keys.PSK, ip); !r.ok() {
s.db.Exec("DELETE FROM users WHERE name = ?", name)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("user created", "name", name, "ip", ip, "gen", cfg.Gen)
return &userRow{Name: name, IP: ip, PubKey: keys.Public, VpnKey: vpnKey, PskKey: keys.PSK, KeyGen: cfg.Gen}, nil
}
func (s *Server) removeUser(name string) error {
u, err := s.userGet(name)
if err != nil {
return err
}
if u == nil {
return errors.New("Пользователь не найден") // в Node — обычный Error → 500
}
if !s.dropFromKernel(u.PubKey) {
return errors.New("Не удалось снять пира с интерфейса")
}
if _, err := s.db.Exec("DELETE FROM users WHERE name = ?", name); err != nil {
return err
}
if err := s.rebuildConf(); err != nil {
return err
}
for _, ext := range []string{".conf", ".key"} {
f := filepath.Join(s.confDir, "clients", name+ext)
if _, err := os.Stat(f); err == nil {
os.Remove(f)
}
}
s.log.Info("user removed", "name", name)
return nil
}
type reissueResult struct {
Total int `json:"total"`
Reissued int `json:"reissued"`
Regenerated []string `json:"regenerated"`
Backup string `json:"backup"`
}
// reissueAll перевыпускает все vpn:// ключи на текущих параметрах интерфейса — после смены
// поколения старые перестают работать. IP, pub_key и psk_key сохраняются: на проводе ничего
// не меняется, клиенту достаточно заново импортировать ключ.
func (s *Server) reissueAll() (*reissueResult, error) {
users, err := s.usersAll()
if err != nil {
return nil, err
}
serverPub, err := s.getServerPublicKey()
if err != nil {
return nil, err
}
backup := s.dbPath + ".bak-" + jsonStamp(time.Now())
if _, err := s.db.Exec("VACUUM INTO ?", backup); err != nil {
return nil, err
}
s.log.Info("reissue: db backed up", "backup", backup, "users", len(users))
res := &reissueResult{Total: len(users), Regenerated: []string{}, Backup: backup}
cfg := s.cfg()
for _, u := range users {
var keys Keys
if priv := clientPrivKeyFrom(u.VpnKey); priv != "" {
keys = Keys{Private: priv, Public: u.PubKey, PSK: u.PskKey}
} else {
// Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую.
s.log.Warn("reissue: vpn_key не декодируется, генерируем новую пару", "name", u.Name)
keys, err = s.generateKeys()
if err != nil {
return nil, err
}
runCmd("awg", "set", s.iface, "peer", u.PubKey, "remove")
if r := s.setPeer(keys.Public, keys.PSK, u.IP); !r.ok() {
s.log.Error("reissue: awg set failed", "name", u.Name, "stderr", r.Stderr)
continue
}
res.Regenerated = append(res.Regenerated, u.Name)
}
vk, err := EncodeVpnKey(s.keyEnv(), keys, u.IP, serverPub)
if err != nil {
return nil, err
}
if _, err := s.db.Exec("UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?",
vk, keys.Public, keys.PSK, cfg.Gen, u.Name); err != nil {
return nil, err
}
res.Reissued++
}
if err := s.syncPeers(); err != nil {
return nil, err
}
s.log.Info("reissue: done", "total", len(users), "reissued", res.Reissued, "regenerated", len(res.Regenerated))
return res, nil
}
// ───────── пиры устройств (sen://) ─────────
// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим публичным
// ключом». Приватного ключа устройства здесь нет и быть не может.
var rePub = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`)
// checkPubKey: base64 от 32 байт (44 символа, последний «=»).
func checkPubKey(k string) (string, error) {
if !rePub.MatchString(k) {
return "", httpErr(400, "Неверный публичный ключ")
}
b, err := b64Std(k)
if err != nil || len(b) != 32 {
return "", httpErr(400, "Неверный публичный ключ")
}
return k, nil
}
// pubKeyFromParam: в пути base64url — «/» и «+» обычного base64 ломают маршрут.
func pubKeyFromParam(p string) (string, error) {
b, err := lenientB64URL(p)
if err != nil {
return "", httpErr(400, "Неверный публичный ключ")
}
return checkPubKey(stdB64(b))
}
type ipPsk struct {
IP string `json:"ip"`
PSK string `json:"psk_key"`
}
func (s *Server) assertPubKeyFree(q queryer, pub string) error {
var n int
if err := q.QueryRow("SELECT COUNT(*) FROM peers WHERE pub_key = ?", pub).Scan(&n); err != nil {
return err
}
taken, err := s.userPubTaken(q, pub)
if err != nil {
return err
}
if n > 0 || taken {
return httpErr(409, "Публичный ключ уже используется")
}
if sp, err := s.getServerPublicKey(); err != nil {
return err
} else if pub == sp {
return httpErr(409, "Публичный ключ уже используется")
}
return nil
}
func (s *Server) addPeer(pub, owner string) (*ipPsk, error) {
if _, err := checkPubKey(pub); err != nil {
return nil, err
}
psk, err := s.genPSK()
if err != nil {
return nil, err
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
var ip string
err = s.assertPubKeyFree(tx, pub)
if err == nil {
ip, err = s.nextIp(tx, s.cfg().Subnet)
}
if err == nil {
_, err = tx.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)",
pub, ip, psk, owner, time.Now().Unix())
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
if r := s.setPeer(pub, psk, ip); !r.ok() {
s.db.Exec("DELETE FROM peers WHERE pub_key = ?", pub)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer added", "owner", owner, "ip", ip)
return &ipPsk{ip, psk}, nil
}
// replacePeer — смена ключа устройства: IP и PSK остаются, меняется только pub_key.
func (s *Server) replacePeer(oldPub, newPub string) (*ipPsk, error) {
if _, err := checkPubKey(newPub); err != nil {
return nil, err
}
old, err := s.peerGet(oldPub)
if err != nil {
return nil, err
}
if old == nil {
return nil, httpErr(404, "Пир не найден")
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
if err = s.assertPubKeyFree(tx, newPub); err == nil {
_, err = tx.Exec("UPDATE peers SET pub_key = ? WHERE pub_key = ?", newPub, oldPub)
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
if r := s.setPeer(newPub, old.PskKey, old.IP); !r.ok() {
s.db.Exec("UPDATE peers SET pub_key = ? WHERE pub_key = ?", oldPub, newPub)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
// Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа
// не откатываем: новый уже стоит, а клиент к этому моменту получил бы ошибку.
if !s.dropFromKernel(oldPub) {
s.log.Error("peer rekey: старый ключ остался в ядре", "owner", old.Owner)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer rekeyed", "owner", old.Owner, "ip", old.IP)
return &ipPsk{old.IP, old.PskKey}, nil
}
func (s *Server) rotatePeerPSK(pub string) (*ipPsk, error) {
p, err := s.peerGet(pub)
if err != nil {
return nil, err
}
if p == nil {
return nil, httpErr(404, "Пир не найден")
}
psk, err := s.genPSK()
if err != nil {
return nil, err
}
if r := s.setPeer(pub, psk, p.IP); !r.ok() {
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if _, err := s.db.Exec("UPDATE peers SET psk_key = ? WHERE pub_key = ?", psk, pub); err != nil {
return nil, err
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer psk rotated", "owner", p.Owner)
return &ipPsk{p.IP, psk}, nil
}
func (s *Server) removePeer(pub string) error {
p, err := s.peerGet(pub)
if err != nil {
return err
}
if p == nil {
return httpErr(404, "Пир не найден")
}
if !s.dropFromKernel(pub) {
return errors.New("Не удалось снять пира с интерфейса")
}
if _, err := s.db.Exec("DELETE FROM peers WHERE pub_key = ?", pub); err != nil {
return err
}
if err := s.rebuildConf(); err != nil {
return err
}
s.log.Info("peer removed", "owner", p.Owner)
return nil
}
var (
rePanelID = regexp.MustCompile(`^[a-z0-9]{8,16}$`)
reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`)
)
const syncMax = 10_000
type legacyPeer struct {
PubKey string `json:"pub_key"`
Owner string `json:"owner"`
IP string `json:"ip"`
}
type syncResult struct {
Present []string `json:"present"`
Removed int `json:"removed"`
Legacy []legacyPeer `json:"legacy"`
}
// syncOwnedPeers: панель присылает полный список ключей, которые ей нужны на этом сервере,
// — всё остальное из её области («<panel>/…») снимается. Удаляем только по такому явному
// списку, никогда по времени или неактивности: панель не на связи — ничего не происходит.
// Пиры без области (owner «m1/d2», выданы до областей) панель забирает себе, только если их
// ключ есть в её списке; остальные не трогаем — чьи они, уже не разобрать.
func (s *Server) syncOwnedPeers(panel string, keep []string) (*syncResult, error) {
want := map[string]bool{}
for _, k := range keep {
want[k] = true
}
prefix := panel + "/"
peers, err := s.peersAll()
if err != nil {
return nil, err
}
var removed []peerRow
claimed := 0
for _, p := range peers {
switch {
case strings.HasPrefix(p.Owner, prefix):
// Ядро первым: не снялся — строка остаётся, следующий sync попробует снова.
if !want[p.PubKey] && s.dropFromKernel(p.PubKey) {
if _, err := s.db.Exec("DELETE FROM peers WHERE pub_key = ?", p.PubKey); err != nil {
return nil, err
}
removed = append(removed, p)
}
case reLegacyOwner.MatchString(p.Owner) && want[p.PubKey]:
if _, err := s.db.Exec("UPDATE peers SET owner = ? WHERE pub_key = ?", prefix+p.Owner, p.PubKey); err != nil {
return nil, err
}
claimed++
}
}
if len(removed) > 0 || claimed > 0 {
if err := s.rebuildConf(); err != nil {
return nil, err
}
}
if len(removed) > 0 {
owners := make([]string, len(removed))
for i, p := range removed {
owners[i] = p.Owner
}
s.log.Info("peers synced out", "panel", panel, "removed", owners)
}
all, err := s.peersAll()
if err != nil {
return nil, err
}
res := &syncResult{Present: []string{}, Removed: len(removed), Legacy: []legacyPeer{}}
for _, p := range all {
if strings.HasPrefix(p.Owner, prefix) {
res.Present = append(res.Present, p.PubKey)
}
if reLegacyOwner.MatchString(p.Owner) {
res.Legacy = append(res.Legacy, legacyPeer{p.PubKey, p.Owner, p.IP})
}
}
return res, nil
}
// ───────── переход 2.0 → 3.1 ─────────
// Header protection использует библиотечный chacha-API, которого нет до ядра 5.5 — там
// модуль 3.x просто не соберётся (upstream issue #210).
func kernelRelease() string {
r := runCmd("uname", "-r")
return strings.TrimSpace(r.Stdout)
}
func kernelSupportsHeaderProtection(release string) bool {
parts := strings.Split(release, ".")
if len(parts) < 2 {
return false
}
maj, e1 := strconv.Atoi(leadingDigits(parts[0]))
min, e2 := strconv.Atoi(leadingDigits(parts[1]))
if e1 != nil || e2 != nil {
return false
}
return maj > 5 || (maj == 5 && min >= 5)
}
func leadingDigits(s string) string {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
return s[:i]
}
// checkGen31Support решает ЗАГРУЖЕННЫЙ модуль, а не тот, что лежит на диске после apt.
func (s *Server) checkGen31Support() string {
module, _ := readVersions()
if !strings.HasPrefix(module, "3.") {
m := module
if m == "" {
m = "неизвестной версии"
}
return fmt.Sprintf("В ядре загружен модуль amneziawg %s — для 3.1 нужна 3.x. ", m) +
"Обнови пакет (apt) и перезагрузи сервер, затем повтори переход."
}
if rel := kernelRelease(); !kernelSupportsHeaderProtection(rel) {
return fmt.Sprintf("Ядро %s старше 5.5 — header protection в нём не работает (upstream issue #210).", rel)
}
return ""
}
var reAwg3KeyLine = regexp.MustCompile(`^\s*(` + strings.Join(AWG3Keys, "|") + `)\s*=`)
// confWithGen31 готовит текст awg1.conf для 3.1: выкидывает старые 3.x-строки (конфиг могли
// править руками), поднимает S1–S4 до допустимых при header protection и дописывает 3.x-блок
// в конец [Interface]. [Peer]-блоки не трогаются — личности пиров переход не меняет.
func confWithGen31(conf, headerKey string) (string, []string) {
var head, rest string
if loc := pPeerSplit.FindStringIndex(conf); loc != nil {
head, rest = conf[:loc[0]], conf[loc[0]:]
} else {
head = conf
}
var lines []string
for _, l := range strings.Split(head, "\n") {
if !reAwg3KeyLine.MatchString(l) {
lines = append(lines, l)
}
}
// Пустые строки и «# имя» в хвосте [Interface] относятся уже к первому [Peer] (их пишет
// rebuildConf) — отделяем, чтобы 3.x-строки встали внутрь [Interface].
var tail []string
for len(lines) > 0 {
t := strings.TrimSpace(lines[len(lines)-1])
if t != "" && !strings.HasPrefix(t, "#") {
break
}
tail = append([]string{lines[len(lines)-1]}, tail...)
lines = lines[:len(lines)-1]
}
if tt := strings.TrimSpace(strings.Join(tail, "\n")); tt != "" {
rest = tt + "\n" + rest
}
iface := strings.Join(lines, "\n")
// S ниже 12 (или вовсе отсутствующий) ядро с header protection не примет. Правка безопасна
// ровно потому, что следом идёт перевыпуск ключей: обе стороны получают новые значения
// одновременно. (Регулярка с \s*, как в Node-версии: захватывает и переводы строк.)
def := DefaultParams()
defaults := map[string]int{"S1": def.S1, "S2": def.S2, "S3": def.S3, "S4": def.S4}
bumped := []string{}
for _, k := range []string{"S1", "S2", "S3", "S4"} {
re := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*(\d+)\s*$`)
m := re.FindStringSubmatch(iface)
cur := -1
if m != nil {
cur, _ = strconv.Atoi(m[1])
}
if m != nil && cur >= minSForHeaderProtection {
continue
}
val := defaults[k]
if minSForHeaderProtection > val {
val = minSForHeaderProtection
}
if m != nil {
// как iface.replace(re, …) без флага g: заменяется только первое совпадение
loc := re.FindStringIndex(iface)
iface = iface[:loc[0]] + fmt.Sprintf("%s = %d", k, val) + iface[loc[1]:]
} else {
iface = trimEndJS(iface) + fmt.Sprintf("\n%s = %d", k, val)
}
was := "нет"
if m != nil {
was = strconv.Itoa(cur)
}
bumped = append(bumped, fmt.Sprintf("%s: %s → %d", k, was, val))
}
a3 := []string{"HeaderProtectionKey = " + headerKey}
for _, kv := range AWG31Defaults {
a3 = append(a3, kv[0]+" = "+kv[1])
}
out := trimEndJS(iface) + "\n" + strings.Join(a3, "\n") + "\n"
if rest != "" {
out += "\n" + rest
}
return out, bumped
}
type upgradeResult struct {
Gen string `json:"gen"`
Backup string `json:"backup"`
BumpedS []string `json:"bumpedS"`
Reissue *reissueResult `json:"reissue"`
}
// upgradeToGen31: единственная настоящая проверка, что ядро приняло параметры, — подъём
// интерфейса, поэтому шаги такие: бэкап конфига → запись 3.1 → restartAwg() → перевыпуск
// ключей. Если ядро отказало, возвращаем прежний конфиг и поднимаем интерфейс обратно:
// операция либо применяется целиком, либо не оставляет следов.
func (s *Server) upgradeToGen31() (*upgradeResult, error) {
before, err := os.ReadFile(s.confPath())
if err != nil {
return nil, err
}
backup := s.confPath() + ".bak-" + jsonStamp(time.Now())
if err := os.WriteFile(backup, before, 0o600); err != nil {
return nil, err
}
hk, err := s.genPSK()
if err != nil {
return nil, err
}
conf, bumped := confWithGen31(string(before), hk)
if err := os.WriteFile(s.confPath(), []byte(conf), 0o600); err != nil {
return nil, err
}
s.log.Info("upgrade: conf rewritten for 3.1", "backup", backup, "bumpedS", bumped)
rerr := s.restartAwg()
if rerr == nil && s.cfg().Gen != "3.1" {
rerr = errors.New("интерфейс поднялся, но конфиг всё ещё 2.0")
}
if rerr != nil {
s.log.Error("upgrade: kernel rejected 3.1, rolling back", "error", rerr)
os.WriteFile(s.confPath(), before, 0o600)
if e2 := s.restartAwg(); e2 != nil {
s.log.Error("upgrade: rollback restart failed", "error", e2)
}
return nil, fmt.Errorf("Ядро не приняло конфиг 3.1 — вернули прежний (копия: %s). "+
"Причина видна в dmesg после `echo \"module amneziawg +p\" > /sys/kernel/debug/dynamic_debug/control`.", backup)
}
// Ключи, выданные на 2.0-параметрах, после смены поколения не подключатся, поэтому
// перевыпускаем сразу — сервер не должен оставаться в состоянии «3.1 поднят, у всех
// нерабочие ключи». Личности пиров сохраняются.
re, err := s.reissueAll()
if err != nil {
return nil, err
}
s.log.Info("upgrade: done", "gen", s.cfg().Gen, "reissued", re.Reissued, "bumpedS", bumped)
return &upgradeResult{Gen: s.cfg().Gen, Backup: backup, BumpedS: bumped, Reissue: re}, nil
}
var _ = jsjson.Undefined
+152
View File
@@ -0,0 +1,152 @@
package ctrl
import (
"os"
"regexp"
"strconv"
"strings"
)
// AWG3Keys — ключи AmneziaWG 3.0/3.1. Порядок — порядок строк в клиентском .conf
// (client/server_scripts/awg/template.conf в amnezia-client). Пустое значение =
// ключ не задан: он не попадает ни в conf, ни в vpn://, и 2.0 остаётся побайтно прежним.
var AWG3Keys = []string{
"HeaderProtectionKey",
"ContentPaddingAddition",
"RekeyAfterTime",
"RekeyTimeout",
"RejectAfterTime",
"KeepaliveTimeout",
"MaxHandshakeAttempts",
"RandomTrailers",
"DisableCookies",
}
// Params — параметры обфускации из [Interface] awg1.conf. Числовые — числа (в
// vpn:// они уходят строками), остальные — строки.
type Params struct {
Jc, Jmin, Jmax int
S1, S2, S3, S4 int
H1, H2, H3, H4 string
I1, I2, I3, I4, I5 string
A3 map[string]string // AWG3Keys → значение ("" = не задано)
}
// DefaultParams — запасные значения на случай, когда awg1.conf не найден.
func DefaultParams() Params {
return Params{
Jc: 6, Jmin: 10, Jmax: 50,
S1: 90, S2: 45, S3: 37, S4: 14,
H1: "1224800044-2116730834",
H2: "2122053282-2133204808",
H3: "2133604274-2140756116",
H4: "2143656228-2147444225",
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
A3: map[string]string{},
}
}
func (p Params) a3(k string) string { return p.A3[k] }
// Gen выводится из параметров, отдельного флага нет: conf — единственный источник
// правды. Header protection — то, что ломает совместимость с 2.0.
func (p Params) Gen() string {
if p.a3("HeaderProtectionKey") != "" {
return "3.1"
}
return "2"
}
var keepaliveByGen = map[string]string{"2": "25", "3.1": "25-35"}
// AWG31Defaults — значения 3.1-параметров для перехода 2.0 → 3.1 (как у install.sh),
// в порядке записи. HeaderProtectionKey генерируется на месте.
var AWG31Defaults = [][2]string{
{"ContentPaddingAddition", "10-100"},
{"RekeyAfterTime", "100-120"},
{"RekeyTimeout", "3-7"},
{"RejectAfterTime", "150-180"},
{"KeepaliveTimeout", "5-15"},
{"MaxHandshakeAttempts", "15-20"},
}
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12.
const minSForHeaderProtection = 12
var (
pPeerSplit = regexp.MustCompile(`(?m)^\[Peer\]`)
numKeys = []string{"Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"}
strKeys = append([]string{"H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"}, AWG3Keys...)
)
// ReadParams читает параметры из [Interface] awg1.conf (всё до первого [Peer]).
//
// Повторяет регулярки Node-версии: `^\s*K\s*=\s*(\d+)` с флагом m. `\s*` в JS
// захватывает и перевод строки, так что строка «I2 = » без значения «съела» бы
// следующую — install.sh таких не пишет, но поведение сохранено намеренно.
func ReadParams(confFile string, warn func(string)) Params {
p := DefaultParams()
raw, err := os.ReadFile(confFile)
if err != nil {
if warn != nil {
warn("awg1.conf не найден — параметры обфускации по умолчанию")
}
return p
}
iface := pPeerSplit.Split(string(raw), 2)[0]
for _, k := range numKeys {
m := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*(\d+)`).FindStringSubmatch(iface)
if m == nil {
continue
}
n, _ := strconv.Atoi(m[1])
switch k {
case "Jc":
p.Jc = n
case "Jmin":
p.Jmin = n
case "Jmax":
p.Jmax = n
case "S1":
p.S1 = n
case "S2":
p.S2 = n
case "S3":
p.S3 = n
case "S4":
p.S4 = n
}
}
for _, k := range strKeys {
// В JS `.` не совпадает с \n, \r, U+2028, U+2029; в Go-режиме (?m) без (?s) — только с \n.
m := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*([^\n\r\x{2028}\x{2029}]*)$`).FindStringSubmatch(iface)
if m == nil {
continue
}
v := strings.TrimSpace(m[1])
switch k {
case "H1":
p.H1 = v
case "H2":
p.H2 = v
case "H3":
p.H3 = v
case "H4":
p.H4 = v
case "I1":
p.I1 = v
case "I2":
p.I2 = v
case "I3":
p.I3 = v
case "I4":
p.I4 = v
case "I5":
p.I5 = v
default:
p.A3[k] = v
}
}
return p
}
+410
View File
@@ -0,0 +1,410 @@
package ctrl
import (
"math"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func nowFn() time.Time { return time.Now() }
type ob = jsjson.Object
type kv = jsjson.KV
func ok(v any) (result, error) { return result{200, v}, nil }
func created(v any) (result, error) { return result{201, v}, nil }
func (s *Server) routes() []route {
a := []func(*rctx) error{s.auth}
an := []func(*rctx) error{s.auth, validateName}
R := func(method, path string, lock bool, mw []func(*rctx) error, h func(*rctx) (result, error)) route {
segs := strings.Split(strings.Trim(path, "/"), "/")
return route{method: method, segs: segs, mw: mw, lock: lock, h: h}
}
return []route{
R("GET", "/health", false, nil, func(c *rctx) (result, error) {
st := s.interfaceStatus()
cfg := s.cfg()
status, word := 200, "ok"
awgSt := "ok"
if !st.Up {
status, word, awgSt = 503, "degraded", "down"
}
return result{status, ob{{"status", word}, {"server", cfg.ServerName}, {"ip", cfg.ServerIP}, {"gen", cfg.Gen},
{"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil
}),
R("POST", "/api/users", true, an, func(c *rctx) (result, error) {
name, _ := c.obj().Get2("name").(string)
if u, err := s.userGet(name); err != nil {
return result{}, err
} else if u != nil {
return result{}, httpErr(409, "Пользователь уже существует")
}
u, err := s.addUser(name)
if err != nil {
return result{}, err
}
return created(u)
}),
R("GET", "/api/users", true, a, func(c *rctx) (result, error) {
rows, err := s.db.Query("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''")
if err != nil {
return result{}, err
}
stats := s.getPeersData()
list := []any{}
for rows.Next() {
var n, ip, pub, vk, kg string
if err := rows.Scan(&n, &ip, &pub, &vk, &kg); err != nil {
rows.Close()
return result{}, err
}
ps := stats[pub]
list = append(list, ob{{"name", n}, {"ip", ip}, {"pub_key", pub}, {"vpn_key", vk}, {"key_gen", kg},
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}})
}
rows.Close()
return ok(ob{{"users", list}})
}),
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
}
stats := s.getPeersData()
list := []any{}
for rows.Next() {
var n, ip, pub, kg string
if err := rows.Scan(&n, &ip, &pub, &kg); err != nil {
rows.Close()
return result{}, err
}
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)},
{"torrent", torrent[ip]}})
}
rows.Close()
return ok(ob{{"users", list}})
}),
// ⚠️ До «/api/users/:name», иначе тот примет «reissue» за имя пользователя.
R("POST", "/api/users/reissue", true, a, func(c *rctx) (result, error) {
r, err := s.reissueAll()
if err != nil {
return result{}, err
}
return ok(r)
}),
R("POST", "/api/users/:name", true, an, func(c *rctx) (result, error) {
u, err := s.userGet(c.params["name"])
if err != nil {
return result{}, err
}
if u == nil {
return result{}, httpErr(404, "Пользователь не найден")
}
return ok(u)
}),
R("DELETE", "/api/users/:name", true, an, func(c *rctx) (result, error) {
if err := s.removeUser(c.params["name"]); err != nil {
return result{}, err
}
return ok(ob{{"success", true}, {"name", c.params["name"]}})
}),
R("POST", "/api/peers", true, a, func(c *rctx) (result, error) {
owner, isStr := c.obj().Get2("owner").(string)
if !isStr || !reOwner.MatchString(owner) {
return result{}, httpErr(400, "Неверный owner")
}
pub, err := checkPubKeyAny(c.obj().Get2("pub_key"))
if err != nil {
return result{}, err
}
r, err := s.addPeer(pub, owner)
if err != nil {
return result{}, err
}
return created(r)
}),
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 {
return result{}, err
}
list := []any{}
for _, p := range peers {
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)}, {"torrent", torrent[p.IP]}})
}
return ok(ob{{"peers", list}})
}),
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять
// их после сбоя `awg set … remove` некому. Только чтение — панель сама решает, что делать.
R("GET", "/api/audit", true, a, func(c *rctx) (result, error) {
r := runCmd("awg", "show", s.iface, "dump")
if !r.ok() {
return result{}, httpErr(503, "awg show не ответил")
}
known, err := s.knownPubKeys()
if err != nil {
return result{}, err
}
now := float64(time.Now().Unix())
ghosts := []any{}
lines := strings.Split(r.Stdout, "\n")
for _, l := range lines[1:] {
f := strings.Split(l, "\t")
if f[0] == "" || known[f[0]] {
continue
}
num := func(i int) float64 {
if i < len(f) {
return orZero(jsNumber(f[i]))
}
return 0
}
hs := num(4)
ghosts = append(ghosts, ob{{"pub_key", f[0]}, {"lastHandshake", hs}, {"online", hs > 0 && now-hs < 180}, {"rx", num(5)}, {"tx", num(6)}})
}
return ok(ob{{"ghosts", ghosts}})
}),
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется:
// пир, который успел появиться в users/peers, не трогаем. Строк в базе у «призраков»
// нет — снимается только ядро.
R("POST", "/api/audit/clean", true, a, func(c *rctx) (result, error) {
arr, isArr := c.obj().Get2("pub_keys").([]any)
if !isArr || len(arr) > syncMax {
return result{}, httpErr(400, "Неверный список ключей")
}
keys, err := checkPubKeys(arr)
if err != nil {
return result{}, err
}
removed, skipped := []string{}, []string{}
for _, k := range keys {
known, err := s.isKnownPub(k)
if err != nil {
return result{}, err
}
skip := known
if !skip {
sp, err := s.getServerPublicKey()
if err != nil {
return result{}, err
}
kp := s.kernelPeers()
skip = k == sp || kp == nil || !kp[k]
}
if skip {
skipped = append(skipped, k)
} else if s.dropFromKernel(k) {
removed = append(removed, k)
} else {
skipped = append(skipped, k)
}
}
if len(removed) > 0 {
s.log.Warn("ghost peers removed from kernel", "count", len(removed), "removed", removed)
}
return ok(ob{{"removed", removed}, {"skipped", skipped}})
}),
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
R("PUT", "/api/peers/sync", true, a, func(c *rctx) (result, error) {
owner, isStr := c.obj().Get2("owner").(string)
if !isStr || !rePanelID.MatchString(owner) {
return result{}, httpErr(400, "Неверный owner")
}
arr, isArr := c.obj().Get2("keep").([]any)
if !isArr || len(arr) > syncMax {
return result{}, httpErr(400, "Неверный список ключей")
}
keep, err := checkPubKeys(arr)
if err != nil {
return result{}, err
}
r, err := s.syncOwnedPeers(owner, keep)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("PUT", "/api/peers/:pub", true, a, func(c *rctx) (result, error) {
oldPub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
newPub, err := checkPubKeyAny(c.obj().Get2("pub_key"))
if err != nil {
return result{}, err
}
r, err := s.replacePeer(oldPub, newPub)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("POST", "/api/peers/:pub/psk", true, a, func(c *rctx) (result, error) {
pub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
r, err := s.rotatePeerPSK(pub)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("DELETE", "/api/peers/:pub", true, a, func(c *rctx) (result, error) {
pub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
if err := s.removePeer(pub); err != nil {
return result{}, err
}
return ok(ob{{"success", true}})
}),
// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf;
// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf.
R("GET", "/api/profile", true, a, func(c *rctx) (result, error) {
sp, err := s.getServerPublicKey()
if err != nil {
return result{}, err
}
cfg := s.cfg()
return ok(ob{{"name", cfg.ServerName}, {"endpoint", cfg.ServerIP + ":" + strconv.Itoa(cfg.ServerPort)},
{"server_pub", sp}, {"gen", cfg.Gen}, {"dns", []string{cfg.DNS1, cfg.DNS2}},
{"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}})
}),
R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) {
if err := s.restartAwg(); err != nil {
return result{}, err
}
return ok(ob{{"success", true}})
}),
// Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей. Ошибку отдаём
// текстом: панель показывает её оператору, а причины отказа (старый модуль, старое ядро,
// отказ ядра на подъёме) требуют разных действий.
R("POST", "/awg/upgrade", true, a, func(c *rctx) (result, error) {
if s.cfg().Gen == "3.1" {
return result{}, httpErr(409, "Сервер уже работает на AmneziaWG 3.1")
}
if blocker := s.checkGen31Support(); blocker != "" {
return result{}, httpErr(409, blocker)
}
r, err := s.upgradeToGen31()
if err != nil {
return result{}, httpErr(500, err.Error())
}
return ok(r)
}),
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не
// пропустит (и без PATCH: агент отвечает на него 400).
R("GET", "/api/shaping", false, a, func(c *rctx) (result, error) { return ok(s.shaping.Status()) }),
R("PUT", "/api/shaping", false, a, func(c *rctx) (result, error) {
b := c.obj()
enabled, e1 := b.Get2("enabled").(bool)
auto, e2 := b.Get2("auto").(bool)
if !e1 || !e2 {
return result{}, httpErr(400, "enabled и auto должны быть true/false")
}
mbit := func(v any, what string) (int, error) {
n := jsToNumber(v)
if math.IsNaN(n) || n != math.Trunc(n) || n < 0 || n > 100_000 {
return 0, httpErr(400, what+": целое число Мбит/с от 1 до 100000")
}
return int(n), nil
}
down, err := mbit(b.Get2("downMbit"), "Загрузка")
if err != nil {
return result{}, err
}
up, err := mbit(b.Get2("upMbit"), "Отдача")
if err != nil {
return result{}, err
}
if enabled && (down < 1 || up < 1) {
return result{}, httpErr(400, "Для включения укажите ширину канала в обе стороны")
}
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
if err := s.shaping.Update(ShapingSettings{Enabled: enabled, DownMbit: down, UpMbit: up, Auto: auto}); err != nil {
return result{}, httpErr(500, err.Error())
}
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 {
return result{}, err
}
return ok(d)
}),
R("POST", "/api/monitor/install", false, a, func(c *rctx) (result, error) {
if err := s.startMonitorJob("install"); err != nil {
return result{}, err
}
return result{202, ob{{"running", true}, {"action", "install"}}}, nil
}),
R("POST", "/api/monitor/remove", false, a, func(c *rctx) (result, error) {
if err := s.startMonitorJob("remove"); err != nil {
return result{}, err
}
return result{202, ob{{"running", true}, {"action", "remove"}}}, nil
}),
R("GET", "/api/monitor/job", false, a, func(c *rctx) (result, error) { return ok(s.monitorView()) }),
R("GET", "/awg/status", true, a, func(c *rctx) (result, error) { return ok(s.interfaceStatus()) }),
R("POST", "/awg/start", true, a, func(c *rctx) (result, error) {
st, err := s.startInterface()
if err != nil {
return result{}, err
}
return ok(st)
}),
}
}
+189
View File
@@ -0,0 +1,189 @@
// Package ctrl — awg-ctrl: привилегированный REST API, который управляет интерфейсом
// AmneziaWG (`awg`, `awg-quick`, `ip`, `tc`, `nft`) и базой пользователей users.db.
// Слушает только 127.0.0.1; снаружи до него добирается лишь awg-ui (и агент ноды, и CLI)
// с подписанным Ed25519-токеном.
package ctrl
import (
"crypto/ed25519"
"database/sql"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"sync"
"time"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/logx"
)
// Config — то, что в Node-версии лежало в CONFIG. Параметры и поколение меняются при
// restartAwg/переходе на 3.1 — читать через Server.cfg().
type Config struct {
ServerIP string
ServerPort int
ServerName string
DNS1, DNS2 string
MTU int
Keepalive string
Subnet string
Params Params
Gen string
AwgModule string
AwgTools string
}
type Server struct {
iface string
confDir string
dbPath string
serverPrivKeyFile string
port int
db *sql.DB
log *logx.Logger
pub ed25519.PublicKey
// mu делает последовательности «БД + awg + awg1.conf» неделимыми. В Node весь код между
// await атомарен (один поток); в Go без этого два запроса перемешали бы шаги.
mu sync.Mutex
cfgMu sync.RWMutex
c Config
shaping *Shaping
torrent *Torrent
metrics metricsCache
monitor monitorJob
cliPath string
cliLog string
}
func (s *Server) cfg() Config {
s.cfgMu.RLock()
defer s.cfgMu.RUnlock()
return s.c
}
func (s *Server) keyEnv() keyEnv {
c := s.cfg()
return keyEnv{P: c.Params, ServerIP: c.ServerIP, ServerPort: c.ServerPort, ServerName: c.ServerName,
DNS1: c.DNS1, DNS2: c.DNS2, MTU: c.MTU, Keepalive: c.Keepalive, Gen: c.Gen, Subnet: c.Subnet}
}
func envOr(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
func getLocalIP() string {
r := runCmd("ip", "route", "show", "default")
m := regexp.MustCompile(`dev\s+(\S+)`).FindStringSubmatch(r.Stdout)
if m == nil {
return ""
}
a := runCmd("ip", "addr", "show", m[1])
if m := regexp.MustCompile(`inet\s+([\d.]+)`).FindStringSubmatch(a.Stdout); m != nil {
return m[1]
}
return ""
}
// New открывает базу и ключи, читает параметры из awg1.conf. Не слушает порт.
func New() (*Server, error) {
s := &Server{
iface: "awg1",
confDir: envOr("AWG_CONF_DIR", "/etc/amnezia/amneziawg"),
serverPrivKeyFile: envOr("AWG_SERVER_PRIV_KEY_FILE", "/etc/amnezia/server_private.key"),
cliPath: envOr("AWG_CLI", "/usr/local/bin/awg-ctrl"),
}
s.dbPath = envOr("AWG_DB_PATH", filepath.Join(s.confDir, "users.db"))
port, _ := strconv.Atoi(os.Getenv("PORT"))
if port == 0 {
port = 3005
}
s.port = port
cwd, _ := os.Getwd()
s.log = logx.New(os.Stdout, filepath.Join(cwd, "logs"))
s.cliLog = envOr("MONITOR_LOG", filepath.Join(filepath.Dir(cwd), "logs", "monitor-job.log"))
pubFile := envOr("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")
pub, err := iauth.LoadPublic(pubFile)
if err != nil {
s.log.Error("FATAL: публичный ключ внутренней авторизации не найден: " + pubFile)
return nil, fmt.Errorf("FATAL: публичный ключ внутренней авторизации не найден: %s", pubFile)
}
s.pub = pub
db, err := openDB(s.dbPath)
if err != nil {
return nil, err
}
s.db = db
// Адрес/порт/имя: БД, затем env, затем (для адреса) маршрут по умолчанию. Как в Node,
// запасной адрес вычисляется всегда, даже если в БД уже есть значение.
fallbackIP := ""
if v, ok := os.LookupEnv("SERVER_IP"); ok {
fallbackIP = v
} else {
fallbackIP = getLocalIP()
}
ip := s.getCfg("serverIp", fallbackIP)
portStr := s.getCfg("serverPort", envOr("SERVER_PORT", "51820"))
name := s.getCfg("serverName", envOr("SERVER_NAME", "VPN"))
if ip == "" {
return nil, fmt.Errorf("SERVER_IP не задан — передай через env при первом запуске")
}
s.setCfg("serverIp", ip)
s.setCfg("serverPort", portStr)
s.setCfg("serverName", name)
sp, _ := strconv.Atoi(portStr)
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
gen := params.Gen()
module, tools := readVersions()
s.c = Config{
ServerIP: ip, ServerPort: sp, ServerName: name,
DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: 1376, Subnet: "10.9",
Keepalive: keepaliveByGen[gen], Params: params, Gen: gen,
AwgModule: module, AwgTools: tools,
}
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
s.shaping = newShaping(s)
s.torrent = newTorrent(s)
return s, nil
}
// Run слушает 127.0.0.1:PORT и выполняет стартовую последовательность Node-версии:
// listen → ensureInterfaceUp → syncPeers → шейпинг. Нет интерфейса — завершаемся с ошибкой:
// health-gate CLI опирается на это (без поднятого awg1 awg-ctrl не нужен).
func (s *Server) Run() error {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
if err != nil {
return err
}
srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 15 * time.Second}
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
c := s.cfg()
s.log.Info("Server started", "port", s.port, "host", "127.0.0.1", "serverName", c.ServerName, "serverIp", c.ServerIP)
if err := s.ensureInterfaceUp(); err != nil {
return err
}
if err := s.syncPeers(); err != nil {
return err
}
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
go s.shaping.Apply()
s.shaping.Start()
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
return <-errc
}
+634
View File
@@ -0,0 +1,634 @@
package ctrl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
//
// Схема:
// загрузка (к клиенту) — 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 зовётся на
// старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
//
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по времени,
// ни по неактивности (правило проекта: удаление только по явному списку от панели).
type ShapingSettings struct {
Enabled bool `json:"enabled"`
DownMbit int `json:"downMbit"`
UpMbit int `json:"upMbit"`
Auto bool `json:"auto"`
}
type QdiscStats struct {
Packets float64 `json:"packets"`
Bytes float64 `json:"bytes"`
Drops float64 `json:"drops"`
Backlog float64 `json:"backlog"`
}
type ShapingStatus struct {
Settings ShapingSettings `json:"settings"`
Active bool `json:"active"`
Congested bool `json:"congested"`
Heavy []string `json:"heavy"`
Devices int `json:"devices"`
RateDown float64 `json:"rateDown"`
RateUp float64 `json:"rateUp"`
Down *QdiscStats `json:"down,omitempty"`
Up *QdiscStats `json:"up,omitempty"`
}
const (
congestOn = 0.85 // доля ширины, выше которой канал считается перегруженным
congestOff = 0.70 // и ниже которой перегрузка снимается (гистерезис)
activeMbit = 0.1 // меньше — устройство не считается активным
heavyMult = 1.5 // тяжёлое: ≥ 1.5 честных долей…
heavyMinFrac = 0.05 // …и не меньше 5% ширины канала
checkEvery = 3 // страховочная проверка qdisc — раз в N тиков
ifbName = "ifb-awg"
nftTable = "awg_shape"
)
// Окна и пороги; переопределение через env — только для тестов на стенде.
func envNum(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
return f
}
}
return def
}
type tick struct{ dt, bytes float64 }
type peerCounters struct {
IP string
Rx, Tx float64
}
// orderedSet — множество с порядком вставки (JS Set): порядок попадает в nft-скрипт.
type orderedSet struct {
keys []string
m map[string]bool
}
func newOrderedSet() *orderedSet { return &orderedSet{m: map[string]bool{}} }
func (o *orderedSet) has(k string) bool { return o.m[k] }
func (o *orderedSet) add(k string) {
if !o.m[k] {
o.m[k] = true
o.keys = append(o.keys, k)
}
}
func (o *orderedSet) del(k string) {
if !o.m[k] {
return
}
delete(o.m, k)
for i, x := range o.keys {
if x == k {
o.keys = append(o.keys[:i], o.keys[i+1:]...)
break
}
}
}
func (o *orderedSet) clear() { o.keys, o.m = nil, map[string]bool{} }
func (o *orderedSet) list() []string { return append([]string(nil), o.keys...) }
func (o *orderedSet) size() int { return len(o.keys) }
type Shaping struct {
s *Server
opMu sync.Mutex // сериализует tc/nft: два параллельных Apply дали бы полусобранные qdisc
mu sync.Mutex // состояние сэмплера
prev map[string][2]float64
history map[string][]tick
histOrder []string
totals []tick
heavy *orderedSet
applied *orderedSet // что сейчас стоит в nft-наборе: тяжёлые ∪ с меткой торрента
congested bool
lastRateDn float64
lastRateUp float64
lastTickAt time.Time
tickNo int
lastError string
windowTicks int
sampleS float64
started bool
}
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(), applied: newOrderedSet(),
sampleS: sampleS, windowTicks: int(math.Max(3, math.Round(windowS/sampleS))),
}
}
func (h *Shaping) Settings() ShapingSettings {
s := h.s
down, _ := strconv.Atoi(s.getCfg("shapeDownMbit", "0"))
up, _ := strconv.Atoi(s.getCfg("shapeUpMbit", "0"))
return ShapingSettings{
Enabled: s.getCfg("shapeEnabled", "0") == "1",
DownMbit: down, UpMbit: up,
Auto: s.getCfg("shapeAuto", "1") == "1",
}
}
func (h *Shaping) save(st ShapingSettings) {
b := func(v bool) string {
if v {
return "1"
}
return "0"
}
h.s.setCfg("shapeEnabled", b(st.Enabled))
h.s.setCfg("shapeDownMbit", strconv.Itoa(st.DownMbit))
h.s.setCfg("shapeUpMbit", strconv.Itoa(st.UpMbit))
h.s.setCfg("shapeAuto", b(st.Auto))
}
func (h *Shaping) noteError(err error) {
h.mu.Lock()
defer h.mu.Unlock()
if m := err.Error(); m != h.lastError { // одинаковую ошибку в лог не спамим
h.s.log.Warn("shaping: " + m)
h.lastError = m
}
}
func (h *Shaping) overhead() int {
if v := os.Getenv("SHAPE_OVERHEAD"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 {
return int(f)
}
}
return 60 + h.s.cfg().Params.S4
}
func shExec(name string, args ...string) (string, error) {
return runCtx(15*time.Second, name, args...)
}
func shExecStdin(name string, args []string, input string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var se bytes.Buffer
cmd.Stderr = &se
cmd.Stdin = strings.NewReader(input)
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(se.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("%s: %s", name, msg)
}
return nil
}
func (h *Shaping) nftApply(heavy []string) error {
elements := ""
if len(heavy) > 0 {
elements = "\n elements = { " + strings.Join(heavy, ", ") + " }"
}
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
script := "\nadd table inet " + nftTable + "\ndelete table inet " + nftTable + "\ntable inet " + nftTable + " {\n" +
" set heavy {\n type ipv4_addr" + elements + "\n }\n" +
" chain postrouting {\n type filter hook postrouting priority -150; policy accept;\n" +
" oifname \"" + h.s.iface + "\" ip daddr @heavy ip dscp set cs1\n }\n}\n"
return shExecStdin("nft", []string{"-f", "-"}, script)
}
func (h *Shaping) hasCake(dev string) bool {
out, err := shExec("tc", "qdisc", "show", "dev", dev, "root")
return err == nil && strings.Contains(out, "cake")
}
func (h *Shaping) hasIngressRedirect() bool {
out, err := shExec("tc", "filter", "show", "dev", h.s.iface, "parent", "ffff:")
return err == nil && strings.Contains(out, "mirred")
}
func (h *Shaping) applyNow() error {
st := h.Settings()
if !st.Enabled || st.DownMbit <= 0 || st.UpMbit <= 0 {
h.removeNow()
return nil
}
iface := h.s.iface
oh := strconv.Itoa(h.overhead())
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
if _, err := shExec("tc", "qdisc", "replace", "dev", iface, "root", "cake",
"bandwidth", fmt.Sprintf("%dmbit", st.DownMbit), "diffserv3", "dual-dsthost", "overhead", oh); err != nil {
return err
}
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
if _, err := shExec("ip", "link", "show", ifbName); err != nil {
if _, err := shExec("ip", "link", "add", ifbName, "type", "ifb"); err != nil {
return err
}
}
if _, err := shExec("ip", "link", "set", ifbName, "up"); err != nil {
return err
}
if _, err := shExec("tc", "qdisc", "replace", "dev", ifbName, "root", "cake",
"bandwidth", fmt.Sprintf("%dmbit", st.UpMbit), "besteffort", "dual-srchost", "overhead", oh); err != nil {
return err
}
if !h.hasIngressRedirect() {
shExec("tc", "qdisc", "del", "dev", iface, "ingress") // ошибки игнорируем
if _, err := shExec("tc", "qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"); err != nil {
return err
}
if _, err := shExec("tc", "filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
"matchall", "action", "mirred", "egress", "redirect", "dev", ifbName); err != nil {
return err
}
}
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))
return nil
}
func (h *Shaping) removeNow() {
iface := h.s.iface
shExec("tc", "qdisc", "del", "dev", iface, "root")
shExec("tc", "qdisc", "del", "dev", iface, "ingress")
shExec("ip", "link", "del", ifbName)
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
h.mu.Unlock()
}
// Apply не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от
// ensureInterfaceUp).
func (h *Shaping) Apply() {
h.opMu.Lock()
err := h.applyNow()
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
// Update сохраняет настройки и применяет их; ошибку применения отдаёт вызывающему —
// пользователю нужна честная причина, а не молчаливый warn.
func (h *Shaping) Update(st ShapingSettings) error {
h.save(st)
h.opMu.Lock()
err := h.applyNow()
h.opMu.Unlock()
if err == nil {
h.mu.Lock()
h.lastError = ""
h.mu.Unlock()
}
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 {
arr = append(arr, t)
for len(arr) > h.windowTicks {
arr = arr[1:]
}
return arr
}
func mbit(arr []tick) float64 {
var dt, by float64
for _, t := range arr {
dt += t.dt
by += t.bytes
}
if dt <= 0 {
return 0
}
return by * 8 / dt / 1e6
}
func (h *Shaping) readPeers() ([]peerCounters, error) {
out, err := runCtx(10*time.Second, "awg", "show", h.s.iface, "dump")
if err != nil {
return nil, err
}
var rows []peerCounters
lines := strings.Split(out, "\n")
if len(lines) > 0 {
lines = lines[1:]
}
for _, l := range lines {
f := strings.Split(l, "\t")
if len(f) < 4 || f[0] == "" || f[3] == "" {
continue
}
ip := strings.SplitN(strings.SplitN(f[3], ",", 2)[0], "/", 2)[0]
rx, tx := 0.0, 0.0
if len(f) > 5 {
rx = orZero(jsNumber(f[5]))
}
if len(f) > 6 {
tx = orZero(jsNumber(f[6]))
}
rows = append(rows, peerCounters{ip, rx, tx})
}
return rows, nil
}
func orZero(f float64) float64 {
if math.IsNaN(f) {
return 0
}
return f
}
func (h *Shaping) tick() {
st := h.Settings()
if !st.Enabled {
return
}
now := time.Now()
h.mu.Lock()
dt := h.sampleS
if !h.lastTickAt.IsZero() {
dt = now.Sub(h.lastTickAt).Seconds()
}
h.lastTickAt = now
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) {
h.s.log.Warn("shaping: qdisc на интерфейсе пропал, применяю заново")
h.Apply()
}
peers, err := h.readPeers()
if err != nil {
h.noteError(err)
return
}
h.mu.Lock()
var totalDown, totalUp float64
seen := map[string]bool{}
for _, p := range peers {
seen[p.IP] = true
before, had := h.prev[p.IP]
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира →
// отрицательная дельта = 0.
dTx, dRx := 0.0, 0.0
if had && p.Tx >= before[1] {
dTx = p.Tx - before[1]
}
if had && p.Rx >= before[0] {
dRx = p.Rx - before[0]
}
h.prev[p.IP] = [2]float64{p.Rx, p.Tx}
totalDown += dTx
totalUp += dRx
if _, ok := h.history[p.IP]; !ok {
h.histOrder = append(h.histOrder, p.IP)
}
h.history[p.IP] = h.pushTick(h.history[p.IP], tick{dt, dTx})
}
var kept []string
for _, ip := range h.histOrder {
if seen[ip] {
kept = append(kept, ip)
continue
}
delete(h.prev, ip)
delete(h.history, ip)
h.heavy.del(ip)
}
h.histOrder = kept
h.totals = h.pushTick(h.totals, tick{dt, totalDown})
h.lastRateDn = totalDown * 8 / dt / 1e6
h.lastRateUp = totalUp * 8 / dt / 1e6
if !st.Auto {
h.heavy.clear()
h.congested = false
h.mu.Unlock()
return
}
if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем
h.mu.Unlock()
return
}
avg := mbit(h.totals)
wasCongested := h.congested
if !h.congested && avg >= congestOn*float64(st.DownMbit) {
h.congested = true
}
if h.congested && avg < congestOff*float64(st.DownMbit) {
h.congested = false
}
next := newOrderedSet()
if h.congested {
type rate struct {
ip string
r float64
}
var rates []rate
for _, ip := range h.histOrder {
if hh := h.history[ip]; len(hh) >= h.windowTicks {
rates = append(rates, rate{ip, mbit(hh)})
}
}
active := 0
for _, r := range rates {
if r.r >= activeMbit {
active++
}
}
share := float64(st.DownMbit) / math.Max(1, float64(active))
for _, r := range rates {
wasHeavy := h.heavy.has(r.ip)
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
if r.r >= math.Max(heavyMult*share, heavyMinFrac*float64(st.DownMbit)) || (wasHeavy && r.r >= share) {
next.add(r.ip)
}
}
}
changed := next.size() != h.heavy.size()
if !changed {
for _, ip := range next.keys {
if !h.heavy.has(ip) {
changed = true
break
}
}
}
if changed || wasCongested != h.congested {
h.heavy = next
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", next.list())
}
h.mu.Unlock()
}
func (h *Shaping) Start() {
h.mu.Lock()
if h.started {
h.mu.Unlock()
return
}
h.started = true
h.mu.Unlock()
go func() {
t := time.NewTicker(time.Duration(h.sampleS * float64(time.Second)))
defer t.Stop()
var busy sync.Mutex
for range t.C {
if !busy.TryLock() { // предыдущий тик ещё идёт — пропускаем, а не копим очередь
continue
}
go func() {
defer busy.Unlock()
h.tick()
}()
}
}()
}
// ───────── состояние ─────────
func (h *Shaping) qdiscStats(dev string) *QdiscStats {
out, err := shExec("tc", "-s", "-j", "qdisc", "show", "dev", dev, "root")
if err != nil {
return nil
}
var arr []map[string]any
if json.Unmarshal([]byte(out), &arr) != nil {
return nil
}
for _, q := range arr {
if q["kind"] == "cake" {
n := func(k string) float64 { f, _ := q[k].(float64); return f }
return &QdiscStats{n("packets"), n("bytes"), n("drops"), n("backlog")}
}
}
return nil
}
func (h *Shaping) PriorityOf(ip string) string {
h.mu.Lock()
defer h.mu.Unlock()
if h.applied.has(ip) { // то, что реально стоит в nft: тяжёлые и с меткой торрента
return "background"
}
return "normal"
}
func (h *Shaping) Status() ShapingStatus {
st := h.Settings()
var active bool
var down, up *QdiscStats
if st.Enabled {
active = h.hasCake(h.s.iface)
down, up = h.qdiscStats(h.s.iface), h.qdiscStats(ifbName)
}
h.mu.Lock()
defer h.mu.Unlock()
devices := 0
for _, hh := range h.history {
if mbit(hh) >= activeMbit {
devices++
}
}
heavy := h.applied.list()
if heavy == nil {
heavy = []string{}
}
round1 := func(f float64) float64 { return math.Round(f*10) / 10 }
return ShapingStatus{
Settings: st, Active: active, Congested: h.congested, Heavy: heavy, Devices: devices,
RateDown: round1(h.lastRateDn), RateUp: round1(h.lastRateUp), Down: down, Up: up,
}
}
+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
}
+218
View File
@@ -0,0 +1,218 @@
package ctrl
import (
"bytes"
"compress/zlib"
"encoding/base64"
"encoding/binary"
"io"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Keys — пара ключей клиента и PSK.
type Keys struct{ Private, Public, PSK string }
// Подмножество CONFIG, нужное для сборки ключа.
type keyEnv struct {
P Params
ServerIP string
ServerPort int
ServerName string
DNS1, DNS2 string
MTU int
Keepalive string
Gen string
Subnet string
}
// buildClientConf — клиентский .conf. Порядок: PrivateKey → AWG params (Jc,S,H,I) →
// 3.x-ключи → Address → DNS.
//
// 🛑 Пустые I2–I5 выводятся как «I2 = » с ОДНИМ хвостовым пробелом (так в рабочих ключах
// Amnezia). Пробел дан отдельной константой, чтобы его не срезала ни IDE, ни инструмент
// правки. Не «чистить».
func buildClientConf(e keyEnv, k Keys, ip, serverPub string) string {
const sp = " "
var awg3 strings.Builder
for _, key := range AWG3Keys {
if v := e.P.a3(key); v != "" {
awg3.WriteString(key + " = " + v + "\n")
}
}
var b strings.Builder
b.WriteString("[Interface]\n")
b.WriteString("PrivateKey = " + k.Private + "\n")
b.WriteString("Jc = " + strconv.Itoa(e.P.Jc) + "\n")
b.WriteString("Jmin = " + strconv.Itoa(e.P.Jmin) + "\n")
b.WriteString("Jmax = " + strconv.Itoa(e.P.Jmax) + "\n")
b.WriteString("S1 = " + strconv.Itoa(e.P.S1) + "\n")
b.WriteString("S2 = " + strconv.Itoa(e.P.S2) + "\n")
b.WriteString("S3 = " + strconv.Itoa(e.P.S3) + "\n")
b.WriteString("S4 = " + strconv.Itoa(e.P.S4) + "\n")
b.WriteString("H1 = " + e.P.H1 + "\n")
b.WriteString("H2 = " + e.P.H2 + "\n")
b.WriteString("H3 = " + e.P.H3 + "\n")
b.WriteString("H4 = " + e.P.H4 + "\n")
b.WriteString("I1 = " + e.P.I1 + "\n")
b.WriteString("I2 =" + sp + "\n")
b.WriteString("I3 =" + sp + "\n")
b.WriteString("I4 =" + sp + "\n")
b.WriteString("I5 =" + sp + "\n")
b.WriteString(awg3.String())
b.WriteString("Address = " + ip + "/32\n")
b.WriteString("DNS = " + e.DNS1 + ", " + e.DNS2 + "\n")
b.WriteString("\n[Peer]\n")
b.WriteString("PublicKey = " + serverPub + "\n")
b.WriteString("PresharedKey = " + k.PSK + "\n")
b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
b.WriteString("Endpoint = " + e.ServerIP + ":" + strconv.Itoa(e.ServerPort) + "\n")
b.WriteString("PersistentKeepalive = " + e.Keepalive + "\n")
return b.String()
}
// awgObject собирает объект параметров в ASCII-алфавитном порядке, в каком его
// сериализует QJsonObject клиента AmneziaVPN. Незаданные 3.x-ключи выбрасываются.
func awgObject(p Params) jsjson.Object {
a := func(k string) any {
if p.a3(k) == "" {
return jsjson.Undefined
}
return p.a3(k)
}
return jsjson.Object{
{"ContentPaddingAddition", a("ContentPaddingAddition")},
{"DisableCookies", a("DisableCookies")},
{"H1", p.H1}, {"H2", p.H2}, {"H3", p.H3}, {"H4", p.H4},
{"HeaderProtectionKey", a("HeaderProtectionKey")},
{"I1", p.I1}, {"I2", ""}, {"I3", ""}, {"I4", ""}, {"I5", ""},
{"Jc", strconv.Itoa(p.Jc)},
{"Jmax", strconv.Itoa(p.Jmax)},
{"Jmin", strconv.Itoa(p.Jmin)},
{"KeepaliveTimeout", a("KeepaliveTimeout")},
{"MaxHandshakeAttempts", a("MaxHandshakeAttempts")},
{"RandomTrailers", a("RandomTrailers")},
{"RejectAfterTime", a("RejectAfterTime")},
{"RekeyAfterTime", a("RekeyAfterTime")},
{"RekeyTimeout", a("RekeyTimeout")},
{"S1", strconv.Itoa(p.S1)}, {"S2", strconv.Itoa(p.S2)},
{"S3", strconv.Itoa(p.S3)}, {"S4", strconv.Itoa(p.S4)},
}
}
// EncodeVpnKey собирает vpn://-ключ в формате AmneziaVPN: JSON → zlib → 4 байта длины
// (BE, несжатой) → base64url без «=».
//
// Содержимое после распаковки совпадает с Node-версией байт в байт (порядок ключей,
// типы: port — число внутри last_config и строка снаружи). Сам поток deflate у Go и
// у C-zlib разный, поэтому строка vpn:// не совпадает побайтно — решение владельца
// проекта: AmneziaVPN читает оба одинаково (qUncompress).
func EncodeVpnKey(e keyEnv, k Keys, ip, serverPub string) (string, error) {
clientConf := buildClientConf(e, k, ip, serverPub)
lastConfig := append(awgObject(e.P),
jsjson.KV{"allowed_ips", []string{"0.0.0.0/0", "::/0"}},
jsjson.KV{"clientId", k.Public},
jsjson.KV{"client_ip", ip},
jsjson.KV{"client_priv_key", k.Private},
jsjson.KV{"client_pub_key", k.Public},
jsjson.KV{"config", clientConf},
jsjson.KV{"hostName", e.ServerIP},
jsjson.KV{"mtu", strconv.Itoa(e.MTU)},
jsjson.KV{"persistent_keep_alive", e.Keepalive},
jsjson.KV{"port", e.ServerPort},
jsjson.KV{"psk_key", k.PSK},
jsjson.KV{"server_pub_key", serverPub},
)
lastConfigJSON, err := jsjson.MarshalIndent(lastConfig, " ")
if err != nil {
return "", err
}
awg := append(awgObject(e.P),
jsjson.KV{"last_config", string(lastConfigJSON)},
jsjson.KV{"port", strconv.Itoa(e.ServerPort)},
jsjson.KV{"protocol_version", e.Gen},
jsjson.KV{"subnet_address", e.Subnet + ".0.0"},
jsjson.KV{"transport_proto", "udp"},
)
outer := jsjson.Object{
{"containers", []any{jsjson.Object{{"container", "amnezia-awg2"}, {"awg", awg}}}},
{"defaultContainer", "amnezia-awg2"},
{"description", e.ServerName},
{"dns1", e.DNS1},
{"dns2", e.DNS2},
{"hostName", e.ServerIP},
{"nameOverriddenByUser", true},
}
js, err := jsjson.Marshal(outer)
if err != nil {
return "", err
}
var zb bytes.Buffer
zw, _ := zlib.NewWriterLevel(&zb, zlib.DefaultCompression)
if _, err := zw.Write(js); err != nil {
return "", err
}
if err := zw.Close(); err != nil {
return "", err
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(js)))
return "vpn://" + base64.RawURLEncoding.EncodeToString(append(hdr[:], zb.Bytes()...)), nil
}
// DecodeVpnKey — обратная к EncodeVpnKey. Как Buffer.from(…, "base64url") в Node,
// терпим паддинг, «+» и «/». Любая ошибка → nil.
func DecodeVpnKey(key string) jsjson.Object {
s := strings.TrimPrefix(key, "vpn://")
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
buf, err := base64.RawURLEncoding.DecodeString(s)
if err != nil || len(buf) <= 4 {
return nil
}
zr, err := zlib.NewReader(bytes.NewReader(buf[4:]))
if err != nil {
return nil
}
defer zr.Close()
raw, err := io.ReadAll(zr)
if err != nil {
return nil
}
v, err := jsjson.Decode(raw)
if err != nil {
return nil
}
o, _ := v.(jsjson.Object)
return o
}
// clientPrivKeyFrom достаёт client_priv_key из last_config: приватный ключ клиента
// нигде больше не хранится, и только так перевыпуск сохраняет личность пира.
func clientPrivKeyFrom(vpnKey string) string {
o := DecodeVpnKey(vpnKey)
if o == nil {
return ""
}
containers, _ := o.Get2("containers").([]any)
if len(containers) == 0 {
return ""
}
c, _ := containers[0].(jsjson.Object)
awg, _ := c.Get2("awg").(jsjson.Object)
lc, ok := awg.Get2("last_config").(string)
if !ok {
return ""
}
v, err := jsjson.Decode([]byte(lc))
if err != nil {
return ""
}
obj, _ := v.(jsjson.Object)
priv, _ := obj.Get2("client_priv_key").(string)
return priv
}
+43
View File
@@ -0,0 +1,43 @@
// Package envfile — правка cli.env (KEY=value построчно): CLI и панель пишут в него одинаково.
package envfile
import (
"os"
"regexp"
"strings"
)
// Update — как String.replace без g: первое KEY=…, а нет его — строка в конец.
func Update(content, key, value string) string {
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
if loc := re.FindStringIndex(content); loc != nil {
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
}
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
}
func Remove(content, key string) string {
var kept []string
for _, l := range strings.Split(content, "\n") {
if !strings.HasPrefix(l, key+"=") {
kept = append(kept, l)
}
}
return strings.Join(kept, "\n")
}
// Set — задать set и убрать remove в файле. Новый файл — 0600 (в нём пароль панели).
func Set(file string, set [][2]string, remove ...string) error {
raw, err := os.ReadFile(file)
if err != nil && !os.IsNotExist(err) {
return err
}
content := string(raw)
for _, kv := range set {
content = Update(content, kv[0], kv[1])
}
for _, k := range remove {
content = Remove(content, k)
}
return os.WriteFile(file, []byte(content), 0o600)
}
+122
View File
@@ -0,0 +1,122 @@
// Package iauth — внутренние токены между awg-ui / awg-agent / CLI и awg-ctrl.
//
// Формат — «JWT» с EdDSA: header {"alg":"EdDSA","typ":"JWT"}, payload {iss,iat,exp},
// подпись Ed25519 над ASCII "h.p". Ключи — PEM от openssl: PKCS#8 (закрытый) и SPKI
// (открытый). Проверка намеренно такая же нестрогая, как у Node-версии: только подпись
// и exp (alg/iss/лишние сегменты не смотрим) — ужесточение отдельным шагом (zero trust).
package iauth
import (
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"os"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func decodePEM(file string) ([]byte, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
blk, _ := pem.Decode(raw)
if blk == nil {
return nil, errors.New("iauth: not a PEM file: " + file)
}
return blk.Bytes, nil
}
// LoadPublic читает открытый ключ (SPKI PEM).
func LoadPublic(file string) (ed25519.PublicKey, error) {
der, err := decodePEM(file)
if err != nil {
return nil, err
}
k, err := x509.ParsePKIXPublicKey(der)
if err != nil {
return nil, err
}
pub, ok := k.(ed25519.PublicKey)
if !ok {
return nil, errors.New("iauth: not an Ed25519 public key")
}
return pub, nil
}
// LoadPrivate читает закрытый ключ (PKCS#8 PEM).
func LoadPrivate(file string) (ed25519.PrivateKey, error) {
der, err := decodePEM(file)
if err != nil {
return nil, err
}
k, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return nil, err
}
priv, ok := k.(ed25519.PrivateKey)
if !ok {
return nil, errors.New("iauth: not an Ed25519 private key")
}
return priv, nil
}
var b64 = base64.RawURLEncoding
// Mint выпускает токен на 60 с.
func Mint(priv ed25519.PrivateKey, iss string, now time.Time) string {
h := b64.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
t := now.Unix()
p := b64.EncodeToString(jsjson.MustMarshal(jsjson.Object{{Key: "iss", Val: iss}, {Key: "iat", Val: t}, {Key: "exp", Val: t + 60}}))
sig := ed25519.Sign(priv, []byte(h+"."+p))
return h + "." + p + "." + b64.EncodeToString(sig)
}
// lenient повторяет Buffer.from(s, "base64url") в Node: терпит паддинг, «+», «/».
func lenient(s string) ([]byte, error) {
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
return b64.DecodeString(s)
}
// Verify — подпись верна и exp в будущем.
func Verify(pub ed25519.PublicKey, token string, now time.Time) bool {
parts := strings.Split(token, ".")
if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return false
}
sig, err := lenient(parts[2])
if err != nil || !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
return false
}
raw, err := lenient(parts[1])
if err != nil {
return false
}
v, err := jsjson.Decode(raw)
if err != nil {
return false
}
obj, ok := v.(jsjson.Object)
if !ok {
return false
}
var exp float64
switch e := obj.Get2("exp").(type) {
case float64:
exp = e
case string: // JS сравнил бы числовую строку как число
f, err := strconv.ParseFloat(strings.TrimSpace(e), 64)
if err != nil {
return false
}
exp = f
default:
return false
}
return exp > float64(now.Unix())
}
+86
View File
@@ -0,0 +1,86 @@
package jsjson
import (
"bytes"
"encoding/json"
"errors"
"io"
)
// Decode разбирает JSON с сохранением порядка ключей: объекты — Object, массивы —
// []any, числа — float64, как JSON.parse. Нужен, чтобы JSON.stringify(JSON.parse(x))
// воспроизводился точно (например, сохранённый профиль сервера с ключами awg).
func Decode(data []byte) (any, error) {
d := json.NewDecoder(bytes.NewReader(data))
d.UseNumber()
v, err := decodeValue(d)
if err != nil {
return nil, err
}
if _, err := d.Token(); err != io.EOF {
return nil, errors.New("jsjson: trailing data")
}
return v, nil
}
func decodeValue(d *json.Decoder) (any, error) {
t, err := d.Token()
if err != nil {
return nil, err
}
switch x := t.(type) {
case json.Delim:
switch x {
case '{':
obj := Object{}
for d.More() {
kt, err := d.Token()
if err != nil {
return nil, err
}
key, ok := kt.(string)
if !ok {
return nil, errors.New("jsjson: object key is not a string")
}
val, err := decodeValue(d)
if err != nil {
return nil, err
}
// повторный ключ в JS перезаписывает значение, оставаясь на первом месте
replaced := false
for i := range obj {
if obj[i].Key == key {
obj[i].Val = val
replaced = true
break
}
}
if !replaced {
obj = append(obj, KV{key, val})
}
}
if _, err := d.Token(); err != nil {
return nil, err
}
return obj, nil
case '[':
arr := []any{}
for d.More() {
val, err := decodeValue(d)
if err != nil {
return nil, err
}
arr = append(arr, val)
}
if _, err := d.Token(); err != nil {
return nil, err
}
return arr, nil
}
return nil, errors.New("jsjson: unexpected delimiter")
case json.Number:
return x.Float64()
default:
return x, nil // string, bool, nil
}
}
+393
View File
@@ -0,0 +1,393 @@
// Package jsjson повторяет JSON.stringify из JavaScript байт в байт.
//
// Зачем: выход Node-версии зашит в форматы, которые проверяют клиенты и хэши —
// распакованное содержимое vpn://, rev конфига sen:// (sha256 от строки),
// подписанные тела ответов /sub/v1, /api/profile. encoding/json отличается
// (экранирует < > & и U+2028/2029, иначе пишет числа, добавляет перевод
// строки, сортирует ключи map), поэтому здесь свой сериализатор.
//
// Порядок ключей — порядок объявления: Object хранит пары как есть, у структур
// берутся поля по порядку (тег json задаёт имя, "-" — пропуск, omitempty —
// пропуск нулевого значения, как undefined в JS).
package jsjson
import (
"bytes"
"encoding/json"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"unicode/utf8"
)
// KV — пара ключ/значение упорядоченного объекта.
type KV struct {
Key string
Val any
}
// Object — JS-объект с сохранённым порядком ключей.
type Object []KV
// Get возвращает значение по ключу.
func (o Object) Get(key string) (any, bool) {
for _, kv := range o {
if kv.Key == key {
return kv.Val, true
}
}
return nil, false
}
// Raw — уже готовый JSON, вставляется как есть (без проверки).
type Raw []byte
// Marshal — аналог JSON.stringify(v).
func Marshal(v any) ([]byte, error) {
var b bytes.Buffer
if err := encode(&b, reflect.ValueOf(v), "", ""); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// MarshalIndent — аналог JSON.stringify(v, null, indent) (у нас indent = " ").
func MarshalIndent(v any, indent string) ([]byte, error) {
var b bytes.Buffer
if err := encode(&b, reflect.ValueOf(v), indent, ""); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// MustMarshal — для значений, которые заведомо сериализуемы.
func MustMarshal(v any) []byte {
b, err := Marshal(v)
if err != nil {
panic(err)
}
return b
}
var (
objectType = reflect.TypeOf(Object(nil))
rawType = reflect.TypeOf(Raw(nil))
jsonRaw = reflect.TypeOf(json.RawMessage(nil))
jsonNumber = reflect.TypeOf(json.Number(""))
)
func encode(b *bytes.Buffer, v reflect.Value, indent, cur string) error {
if !v.IsValid() {
b.WriteString("null")
return nil
}
switch v.Type() {
case objectType:
return encodeObject(b, v.Interface().(Object), indent, cur)
case rawType:
b.Write(v.Bytes())
return nil
case jsonRaw:
raw := v.Bytes()
if len(raw) == 0 {
b.WriteString("null")
return nil
}
// JSON.stringify(JSON.parse(x)): нормализуем, не доверяя форме исходника.
val, err := Decode(raw)
if err != nil {
return err
}
return encode(b, reflect.ValueOf(val), indent, cur)
case jsonNumber:
f, err := strconv.ParseFloat(v.String(), 64)
if err != nil {
return err
}
b.WriteString(Number(f))
return nil
}
switch v.Kind() {
case reflect.Interface, reflect.Pointer:
if v.IsNil() {
b.WriteString("null")
return nil
}
return encode(b, v.Elem(), indent, cur)
case reflect.Bool:
if v.Bool() {
b.WriteString("true")
} else {
b.WriteString("false")
}
case reflect.String:
writeString(b, v.String())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
b.WriteString(Number(float64(v.Int())))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
b.WriteString(Number(float64(v.Uint())))
case reflect.Float32, reflect.Float64:
b.WriteString(Number(v.Float()))
case reflect.Slice:
if v.IsNil() {
// nil-срез — это пустой массив, а не null: в JS неоткуда взяться null вместо []
b.WriteString("[]")
return nil
}
fallthrough
case reflect.Array:
return encodeArray(b, v, indent, cur)
case reflect.Map:
// В JS порядок ключей — порядок вставки; у Go-map его нет. Разрешаем только
// map[string]..., сортируя ключи, — вызывающий отвечает за то, что это совпадает.
if v.Type().Key().Kind() != reflect.String {
return fmt.Errorf("jsjson: map key must be string, got %s", v.Type().Key())
}
keys := v.MapKeys()
ks := make([]string, len(keys))
for i, k := range keys {
ks[i] = k.String()
}
sortStrings(ks)
obj := make(Object, 0, len(ks))
for _, k := range ks {
obj = append(obj, KV{k, v.MapIndex(reflect.ValueOf(k).Convert(v.Type().Key())).Interface()})
}
return encodeObject(b, obj, indent, cur)
case reflect.Struct:
return encodeObject(b, structObject(v), indent, cur)
default:
return fmt.Errorf("jsjson: unsupported kind %s", v.Kind())
}
return nil
}
func encodeArray(b *bytes.Buffer, v reflect.Value, indent, cur string) error {
n := v.Len()
if n == 0 {
b.WriteString("[]")
return nil
}
b.WriteByte('[')
inner := cur + indent
for i := 0; i < n; i++ {
if i > 0 {
b.WriteByte(',')
}
if indent != "" {
b.WriteByte('\n')
b.WriteString(inner)
}
// undefined/функция в массиве — null; у нас таких нет, кроме nil
if err := encode(b, v.Index(i), indent, inner); err != nil {
return err
}
}
if indent != "" {
b.WriteByte('\n')
b.WriteString(cur)
}
b.WriteByte(']')
return nil
}
// Omit — значение, которое JSON.stringify пропускает (undefined): ключ не выводится.
type omit struct{}
// Undefined — маркер «ключа нет», как undefined в JS-объекте.
var Undefined = omit{}
func encodeObject(b *bytes.Buffer, o Object, indent, cur string) error {
first := true
inner := cur + indent
b.WriteByte('{')
for _, kv := range o {
if _, skip := kv.Val.(omit); skip {
continue
}
if !first {
b.WriteByte(',')
}
first = false
if indent != "" {
b.WriteByte('\n')
b.WriteString(inner)
}
writeString(b, kv.Key)
b.WriteByte(':')
if indent != "" {
b.WriteByte(' ')
}
if err := encode(b, reflect.ValueOf(kv.Val), indent, inner); err != nil {
return err
}
}
if !first && indent != "" {
b.WriteByte('\n')
b.WriteString(cur)
}
b.WriteByte('}')
return nil
}
func structObject(v reflect.Value) Object {
t := v.Type()
obj := make(Object, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
name := f.Name
omitEmpty := false
if tag, ok := f.Tag.Lookup("json"); ok {
if tag == "-" {
continue
}
parts := strings.Split(tag, ",")
if parts[0] != "" {
name = parts[0]
}
for _, p := range parts[1:] {
if p == "omitempty" {
omitEmpty = true
}
}
}
fv := v.Field(i)
if omitEmpty && isEmpty(fv) {
continue
}
obj = append(obj, KV{name, fv.Interface()})
}
return obj
}
func isEmpty(v reflect.Value) bool {
switch v.Kind() {
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
return v.Len() == 0
case reflect.Bool:
return !v.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Interface, reflect.Pointer:
return v.IsNil()
}
return false
}
// writeString экранирует строку как JSON.stringify (ES2019): " \ и управляющие
// символы; \b \f \n \r \t коротко, прочие < 0x20 — \u00xx строчными. Не трогает
// / < > & и U+2028/2029. Неверный UTF-8 заменяется на U+FFFD (в JS его не бывает).
func writeString(b *bytes.Buffer, s string) {
b.WriteByte('"')
for i := 0; i < len(s); {
c := s[i]
if c < utf8.RuneSelf {
switch c {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
if c < 0x20 {
fmt.Fprintf(b, `\u%04x`, c)
} else {
b.WriteByte(c)
}
}
i++
continue
}
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size == 1 {
b.WriteString("�")
} else {
b.WriteString(s[i : i+size])
}
i += size
}
b.WriteByte('"')
}
// Number форматирует число как String(x) в JS (Number::toString), а NaN/±Infinity —
// как null, что делает JSON.stringify.
func Number(x float64) string {
if math.IsNaN(x) || math.IsInf(x, 0) {
return "null"
}
if x == 0 {
return "0" // и -0
}
neg := x < 0
if neg {
x = -x
}
// Кратчайшие цифры, однозначно восстанавливающие x: d.ddddde±n
e := strconv.FormatFloat(x, 'e', -1, 64)
mant, expStr, _ := strings.Cut(e, "e")
digits := strings.Replace(mant, ".", "", 1)
exp, _ := strconv.Atoi(expStr)
k := len(digits) // число значащих цифр
n := exp + 1 // позиция десятичной точки (ECMA-262 Number::toString)
var out string
switch {
case k <= n && n <= 21:
out = digits + strings.Repeat("0", n-k)
case 0 < n && n <= 21:
out = digits[:n] + "." + digits[n:]
case -6 < n && n <= 0:
out = "0." + strings.Repeat("0", -n) + digits
default:
sign := "+"
if n-1 < 0 {
sign = "-"
}
ee := n - 1
if ee < 0 {
ee = -ee
}
if k == 1 {
out = digits + "e" + sign + strconv.Itoa(ee)
} else {
out = digits[:1] + "." + digits[1:] + "e" + sign + strconv.Itoa(ee)
}
}
if neg {
return "-" + out
}
return out
}
func sortStrings(s []string) {
// вставками: ключей мало, а сортировка строк в JS-порядке — по UTF-16, что для
// наших ASCII-ключей совпадает с побайтовой
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
// Get2 — Get без признака наличия (nil, если ключа нет); для цепочек разбора.
func (o Object) Get2(key string) any {
v, _ := o.Get(key)
return v
}
+84
View File
@@ -0,0 +1,84 @@
package jsjson
import (
"encoding/json"
"os"
"testing"
)
type fixture struct {
Strings []struct{ In, Out string } `json:"strings"`
Numbers []struct {
In json.RawMessage `json:"in"`
Out string `json:"out"`
Str string `json:"str"`
} `json:"numbers"`
Objects []struct {
In json.RawMessage `json:"in"`
Compact string `json:"compact"`
Indent2 string `json:"indent2"`
} `json:"objects"`
}
// Эталон — вывод JSON.stringify самого Node (tools/golden/jsjson.mjs).
func TestAgainstNode(t *testing.T) {
raw, err := os.ReadFile("../../testdata/golden/jsjson.json")
if err != nil {
t.Fatal(err)
}
var f fixture
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
for _, c := range f.Strings {
if got := string(MustMarshal(c.In)); got != c.Out {
t.Errorf("string %q: got %s want %s", c.In, got, c.Out)
}
}
for _, c := range f.Numbers {
v, err := Decode(c.In)
if err != nil {
t.Fatal(err)
}
if s, ok := v.(string); ok { // NaN / Infinity переданы строкой
_ = s
continue
}
x := v.(float64)
if got := Number(x); got != c.Str {
t.Errorf("String(%s): got %s want %s", c.In, got, c.Str)
}
if got := string(MustMarshal(x)); got != c.Out {
t.Errorf("JSON.stringify(%s): got %s want %s", c.In, got, c.Out)
}
}
for _, c := range f.Objects {
v, err := Decode(c.In)
if err != nil {
t.Fatal(err)
}
if got := string(MustMarshal(v)); got != c.Compact {
t.Errorf("compact:\n got %s\nwant %s", got, c.Compact)
}
got, _ := MarshalIndent(v, " ")
if string(got) != c.Indent2 {
t.Errorf("indent:\n got %s\nwant %s", got, c.Indent2)
}
}
}
func TestStructOrderAndOmit(t *testing.T) {
type S struct {
B string `json:"b"`
A int `json:"a"`
Opt string `json:"opt,omitempty"`
Skip string `json:"-"`
}
if got := string(MustMarshal(S{B: "x", A: 1})); got != `{"b":"x","a":1}` {
t.Fatal(got)
}
o := Object{{"x", 1}, {"gone", Undefined}, {"y", []string(nil)}}
if got := string(MustMarshal(o)); got != `{"x":1,"y":[]}` {
t.Fatal(got)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package logx — простой логгер: человекочитаемые строки в консоль и JSON-строки в
// файл (как winston в Node-версии: console «<ts> level: msg», файл {level,message,timestamp,…}).
package logx
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
)
type Logger struct {
mu sync.Mutex
out io.Writer
file *os.File
}
// New пишет в stdout и (если dir не пуст) в dir/app.log.
func New(out io.Writer, dir string) *Logger {
l := &Logger{out: out}
if dir != "" {
if err := os.MkdirAll(dir, 0o755); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "app.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
l.file = f
}
}
}
return l
}
func (l *Logger) log(level, msg string, kv []any) {
l.mu.Lock()
defer l.mu.Unlock()
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
if l.out != nil {
fmt.Fprintf(l.out, "%s %s: %s\n", ts, level, msg)
}
if l.file != nil {
m := map[string]any{"level": level, "message": msg, "timestamp": ts}
for i := 0; i+1 < len(kv); i += 2 {
if k, ok := kv[i].(string); ok {
if e, isErr := kv[i+1].(error); isErr {
m[k] = e.Error()
} else {
m[k] = kv[i+1]
}
}
}
b, _ := json.Marshal(m)
l.file.Write(append(b, '\n'))
}
}
func (l *Logger) Info(msg string, kv ...any) { l.log("info", msg, kv) }
func (l *Logger) Warn(msg string, kv ...any) { l.log("warn", msg, kv) }
func (l *Logger) Error(msg string, kv ...any) { l.log("error", msg, kv) }
func (l *Logger) Debug(msg string, kv ...any) { l.log("debug", msg, kv) }
+382
View File
@@ -0,0 +1,382 @@
// Package migrate — перенос панели с сервера «панель + VPN» на отдельную панель без VPN: архив
// (ui.db, ключи подписки и хаба, порты), его раскладка на новой панели и нода #0. Общий для
// CLI (`awg-control migrate`) и вкладки «Перенос» в панели. Порядок и смысл —
// docs/node-protocol.md, «Перенос панели».
//
// Старый сервер остаётся нодой #0: id сервера в ui.db не меняется, поэтому конфиг клиента
// (servers[] и ключи) тот же байт в байт, а пиры на его awg-ctrl уже принадлежат области
// перенесённой панели (sub_meta.panel_id едет вместе с ui.db).
package migrate
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64url = base64.RawURLEncoding
const (
Version = 1
maxFile = 1 << 30 // ui.db крупнее гигабайта — явно не то
JoinTTL = 3600 // как у панели: одноразовый секрет живёт час
)
// File — что едет в архиве: имя внутри архива и переменная пути в окружении awg-ui.
type File struct {
Name, Env string
Required bool
}
var Files = []File{
{"ui.db", "UI_DB_FILE", true},
// 🛑 Ключ подписи и TLS подписки зашиты во все выданные sen://, сертификат хаба — во все
// join-строки и подключённые ноды: без них перенос ломает и ссылки, и ноды.
{"sub_sign.key", "SUB_SIGN_KEY_FILE", false},
{"sub_tls.key", "SUB_TLS_KEY_FILE", false},
{"sub_tls.crt", "SUB_TLS_CERT_FILE", false},
{"node_tls.key", "NODE_TLS_KEY_FILE", false},
{"node_tls.crt", "NODE_TLS_CERT_FILE", false},
}
// Paths — имя файла в архиве → путь на этой машине.
type Paths map[string]string
// PathsFrom — пути из переменных окружения awg-ui (get(env) → путь).
func PathsFrom(get func(env string) string) Paths {
p := Paths{}
for _, f := range Files {
p[f.Name] = get(f.Env)
}
return p
}
// OpenDB — ui.db тем же драйвером, что у awg-ui.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", "file:"+(&url.URL{Path: path}).EscapedPath()+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// ── Архив ───────────────────────────────────────────────────────────────────
// Write — архив в w: снимок db (VACUUM INTO — целостный, вместе с WAL), файлы из p и meta
// (+ panel_id из базы). Отсутствующие необязательные файлы пропускаются.
func Write(w io.Writer, db *sql.DB, p Paths, meta jsjson.Object) error {
tmp, err := os.MkdirTemp("", "forgetting-migrate-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
snap := filepath.Join(tmp, "ui.db")
if _, err := db.Exec("VACUUM INTO ?", snap); err != nil {
return fmt.Errorf("снимок ui.db: %w", err)
}
var panelID string
db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&panelID)
meta = append(jsjson.Object{{Key: "v", Val: Version}, {Key: "panel_id", Val: panelID}}, meta...)
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
add := func(name string, data []byte) error {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
err = add("meta.json", jsjson.MustMarshal(meta))
for _, f := range Files {
if err != nil {
break
}
src := p[f.Name]
if f.Name == "ui.db" {
src = snap
}
raw, rerr := os.ReadFile(src)
if errors.Is(rerr, os.ErrNotExist) && !f.Required {
continue
}
if rerr != nil {
err = rerr
break
}
err = add(f.Name, raw)
}
if cerr := tw.Close(); err == nil {
err = cerr
}
if cerr := gz.Close(); err == nil {
err = cerr
}
return err
}
type Bundle struct {
Dir string
Meta jsjson.Object
Has map[string]bool
}
func (b *Bundle) Close() { os.RemoveAll(b.Dir) }
func (b *Bundle) Num(k string) int {
f, _ := b.Meta.Get2(k).(float64)
return int(f)
}
func (b *Bundle) Str(k string) string {
s, _ := b.Meta.Get2(k).(string)
return s
}
// Read — распаковка во временный каталог; в архиве допустимы только известные имена.
func Read(r io.Reader) (*Bundle, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("не архив переноса: %w", err)
}
dir, err := os.MkdirTemp("", "forgetting-import-")
if err != nil {
return nil, err
}
b := &Bundle{Dir: dir, Has: map[string]bool{}}
fail := func(err error) (*Bundle, error) { b.Close(); return nil, err }
allowed := map[string]bool{"meta.json": true}
for _, f := range Files {
allowed[f.Name] = true
}
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fail(fmt.Errorf("архив повреждён: %w", err))
}
if h.Typeflag != tar.TypeReg || !allowed[h.Name] || b.Has[h.Name] || h.Size > maxFile {
return fail(fmt.Errorf("в архиве лишнее: %q", h.Name))
}
dst, err := os.OpenFile(filepath.Join(dir, h.Name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
_, err = io.Copy(dst, io.LimitReader(tr, maxFile))
if cerr := dst.Close(); err == nil {
err = cerr
}
}
if err != nil {
return fail(err)
}
b.Has[h.Name] = true
}
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
var v any
if err == nil {
v, err = jsjson.Decode(raw)
}
meta, ok := v.(jsjson.Object)
if err != nil || !ok || meta.Get2("v") != float64(Version) || !b.Has["ui.db"] {
return fail(errors.New("не архив переноса или от другой версии (нужны meta.json v1 и ui.db)"))
}
b.Meta = meta
return b, nil
}
// PanelData — сколько в базе того, что перенос затёр бы (мастер-ключи, ноды, ключи API).
func PanelData(file string) (int, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
return 0, nil
}
db, err := OpenDB(file)
if err != nil {
return 0, err
}
defer db.Close()
return Count(db), nil
}
func Count(db *sql.DB) int {
total := 0
for _, t := range []string{"master_keys", "nodes", "api_keys"} {
var n int
// Таблицы может не быть — awg-ui ещё не запускался.
if db.QueryRow("SELECT COUNT(*) FROM "+t).Scan(&n) == nil {
total += n
}
}
return total
}
// ── Раскладка на новой панели ──────────────────────────────────────────────
// Plan — порты и переменные cli.env новой панели. Подписка и хаб — на прежних портах: они в
// ссылках, у нод и в DNS, если адрес — домен.
type Plan struct {
SubPort, NodePort, UIPort int
Host string
Env [][2]string
}
// NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес.
func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) {
p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host}
if p.SubPort == 0 {
p.SubPort = env("SUB_PORT")
}
if p.NodePort == 0 {
p.NodePort = env("NODE_PORT")
}
if p.NodePort == 0 {
return nil, errors.New("приём нод не настроен (NODE_PORT): старый сервер подключится к панели нодой")
}
if p.UIPort != 0 && (p.UIPort == p.SubPort || p.UIPort == p.NodePort) || p.SubPort != 0 && p.SubPort == p.NodePort {
return nil, fmt.Errorf("порты пересекаются: панель %d, подписка %d, хаб %d — смените UI_PORT", p.UIPort, p.SubPort, p.NodePort)
}
for _, kv := range [][2]string{{"SUB_PORT", strconv.Itoa(p.SubPort)}, {"NODE_PORT", strconv.Itoa(p.NodePort)}, {"SUB_HOST", host}} {
if kv[1] != "0" && kv[1] != "" {
p.Env = append(p.Env, kv)
}
}
tlsOn := "off"
if b.Has["sub_tls.crt"] && b.Meta.Get2("sub_tls") == true {
tlsOn = "on"
}
p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn})
// С этого момента панель считает, кто из клиентов и нод узнал её новый адрес.
p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)})
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
if ip := b.Str("server_ip"); ip != "" {
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
return p, nil
}
// TrustRelay — ещё один адрес ретранслятора: старый сервер может выходить в сеть не с того IP,
// что у него в SERVER_IP (NAT, второй адрес). Панель видит, откуда пришёл архив, — его и берём.
func (p *Plan) TrustRelay(ip string) {
if ip == "" {
return
}
for i, kv := range p.Env {
if kv[0] == "RELAY_FROM" {
for _, have := range strings.Split(kv[1], ",") {
if have == ip {
return
}
}
p.Env[i][1] += "," + ip
return
}
}
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
// Install — файлы архива на свои места; прежние уходят в *.bak-<время>.
func Install(b *Bundle, p Paths) error {
stamp := time.Now().Format("20060102-150405")
for _, f := range Files {
if !b.Has[f.Name] {
continue
}
dst := p[f.Name]
olds := []string{dst}
if f.Name == "ui.db" {
olds = append(olds, dst+"-wal", dst+"-shm")
}
for _, o := range olds {
if _, err := os.Stat(o); err == nil {
if err := os.Rename(o, o+".bak-"+stamp); err != nil {
return fmt.Errorf("не удалось отложить %s: %w", o, err)
}
}
}
raw, err := os.ReadFile(filepath.Join(b.Dir, f.Name))
if err == nil {
err = os.MkdirAll(filepath.Dir(dst), 0o700)
}
if err == nil {
err = os.WriteFile(dst, raw, 0o600)
}
if err != nil {
return fmt.Errorf("не удалось записать %s: %w", dst, err)
}
}
return nil
}
// AddNodeZero — старый сервер в реестре нод под прежним id 0, ждёт первого подключения.
func AddNodeZero(dbFile, name string, secret []byte) error {
if name == "" || len([]rune(name)) > 40 || strings.ContainsAny(name, "\r\n<>") {
name = "VPN"
}
db, err := OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
h := sha256.Sum256(secret)
now := time.Now().Unix()
if _, err := db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, ?, ?, ?, ?)",
name, h[:], now+JoinTTL, now); err != nil {
return fmt.Errorf("нода #0 не заведена (база со старой версии? обновите старый сервер): %w", err)
}
return nil
}
// CertPin — SHA-256 SPKI сертификата, base64url: то же, что pin в join-строке.
func CertPin(file string) (string, error) {
raw, err := os.ReadFile(file)
if err != nil {
return "", err
}
blk, _ := pem.Decode(raw)
if blk == nil {
return "", errors.New("не PEM")
}
cert, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64url.EncodeToString(sum[:]), nil
}
// JoinString — как у панели (nodes.go), плюс ui/sub: куда ретранслятору старого сервера слать
// прежние порты панели и подписки. Остальные читатели строки лишних полей не видят.
func JoinString(host string, port int, pin string, id int64, secret []byte, uiPort, subPort int) string {
o := jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: port},
{Key: "pin", Val: pin}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}
if uiPort > 0 {
o = append(o, jsjson.KV{Key: "ui", Val: uiPort})
}
if subPort > 0 {
o = append(o, jsjson.KV{Key: "sub", Val: subPort})
}
return "awgjoin://" + b64url.EncodeToString(jsjson.MustMarshal(o))
}
+176
View File
@@ -0,0 +1,176 @@
package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testDB(t *testing.T, file string) {
t.Helper()
db, err := OpenDB(file)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, q := range []string{
"CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
"INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')",
"CREATE TABLE nodes (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pub TEXT, join_hash BLOB, join_expires INTEGER, created_at INTEGER NOT NULL, last_seen INTEGER, version TEXT)",
"CREATE TABLE master_keys (id INTEGER PRIMARY KEY)",
"INSERT INTO master_keys VALUES (1)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(err)
}
}
}
// Архив → раскладка: всё, что зашито в ссылки и у нод, едет как есть; база — целостным
// снимком, в котором новая панель заводит ноду #0.
func TestBundleRoundTrip(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
paths := func(dir string) Paths {
return PathsFrom(func(env string) string { return filepath.Join(dir, strings.ToLower(env)) })
}
from, to := paths(src), paths(dst)
testDB(t, from["ui.db"])
for name, v := range map[string]string{"sub_sign.key": "sign", "sub_tls.key": "tlskey", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
os.WriteFile(from[name], []byte(v), 0o600)
}
os.WriteFile(to["sub_sign.key"], []byte("прежний"), 0o600)
db, _ := OpenDB(from["ui.db"])
var buf bytes.Buffer
err := Write(&buf, db, from, jsjson.Object{{Key: "server_ip", Val: "198.51.100.1"}, {Key: "sub_port", Val: 40123},
{Key: "sub_tls", Val: true}, {Key: "node_port", Val: 40500}})
db.Close()
if err != nil {
t.Fatal(err)
}
b, err := Read(&buf)
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Num("sub_port") != 40123 || b.Has["node_tls.crt"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
if n, err := PanelData(to["ui.db"]); err != nil || n != 0 {
t.Errorf("PanelData(нет файла) = %d, %v", n, err)
}
plan, err := NewPlan(b, func(string) int { return 0 }, 33000, "203.0.113.7")
if err != nil {
t.Fatal(err)
}
got := regexp.MustCompile(`"MOVED_IN_AT","\d+"`).ReplaceAllString(string(jsjson.MustMarshal(plan.Env)), `"MOVED_IN_AT","…"`)
if got != `[["SUB_PORT","40123"],["NODE_PORT","40500"],["SUB_HOST","203.0.113.7"],["SUB_TLS","on"],["MOVED_IN_AT","…"],["RELAY_FROM","198.51.100.1"]]` {
t.Errorf("env: %s", got)
}
if _, err := NewPlan(b, func(string) int { return 0 }, 40500, "h"); err == nil {
t.Error("UI_PORT = порт хаба: ожидали ошибку")
}
if err := Install(b, to); err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{"sub_sign.key": "sign", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
if raw, _ := os.ReadFile(to[name]); string(raw) != want {
t.Errorf("%s: %q", name, raw)
}
}
if baks, _ := filepath.Glob(to["sub_sign.key"] + ".bak-*"); len(baks) != 1 {
t.Errorf("прежний ключ не отложен: %v", baks)
}
if n, _ := PanelData(to["ui.db"]); n != 1 {
t.Errorf("PanelData(снимок) = %d", n)
}
if err := AddNodeZero(to["ui.db"], "VPN\n<script>", []byte("0123456789abcdef")); err != nil {
t.Fatal(err)
}
db, _ = OpenDB(to["ui.db"])
var name string
db.QueryRow("SELECT name FROM nodes WHERE id = 0").Scan(&name)
db.Close()
if name != "VPN" {
t.Errorf("нода #0: %q", name)
}
if err := AddNodeZero(to["ui.db"], "VPN", []byte("x")); err == nil {
t.Error("вторая нода #0: ожидали ошибку")
}
}
func TestReadRejectsForeignEntries(t *testing.T) {
for name, entries := range map[string][]string{
"лишний файл": {"meta.json", "ui.db", "../../etc/passwd"},
"без базы": {"meta.json"},
"повтор": {"meta.json", "ui.db", "ui.db"},
} {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, e := range entries {
data := []byte("x")
if e == "meta.json" {
data = []byte(`{"v":1}`)
}
tw.WriteHeader(&tar.Header{Name: e, Mode: 0o600, Size: int64(len(data))})
tw.Write(data)
}
tw.Close()
gz.Close()
if b, err := Read(&buf); err == nil {
b.Close()
t.Errorf("%s: архив принят", name)
}
}
}
func TestJoinString(t *testing.T) {
s := JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123)
raw, _ := b64url.DecodeString(strings.TrimPrefix(s, "awgjoin://"))
// Поля сверх v1 — в конце: install.sh и старые CLI их не замечают.
if want := `{"v":1,"h":"203.0.113.7","p":40500,"pin":"` + strings.Repeat("A", 43) + `","n":0,"s":"MDEyMzQ1Njc4OWFiY2RlZg","ui":33100,"sub":40123}`; string(raw) != want {
t.Errorf("join: %s", raw)
}
}
func TestCodeAndSeal(t *testing.T) {
c := Code{Host: "2001:db8::7", Port: 33000, Key: NewKey()}
got, err := ParseCode(" " + c.String() + "\n")
if err != nil || got.Host != c.Host || got.Port != c.Port || !bytes.Equal(got.Key, c.Key) {
t.Fatalf("ParseCode: %+v %v", got, err)
}
if u := got.URL(); u != "http://[2001:db8::7]:33000/migrate/v1/import" {
t.Errorf("URL: %s", u)
}
for _, bad := range []string{"", "awgmove://", "awgjoin://e30", CodePrefix + "e30",
Code{Host: "h/../x", Port: 1, Key: c.Key}.String(), Code{Host: "h", Port: 0, Key: c.Key}.String(), Code{Host: "h", Port: 1, Key: []byte("short")}.String()} {
if _, err := ParseCode(bad); err == nil {
t.Errorf("ParseCode(%q): ожидали ошибку", bad)
}
}
box := SealRequest(c.Key, []byte("архив"))
if plain, err := OpenRequest(c.Key, box); err != nil || string(plain) != "архив" {
t.Errorf("OpenRequest: %q %v", plain, err)
}
if _, err := OpenResponse(c.Key, box); err == nil {
t.Error("запрос открылся как ответ")
}
if _, err := OpenRequest(NewKey(), box); err == nil {
t.Error("открылся чужим ключом")
}
box[len(box)-1] ^= 1
if _, err := OpenRequest(c.Key, box); err == nil {
t.Error("открылся повреждённый")
}
}
+115
View File
@@ -0,0 +1,115 @@
package migrate
// Перенос из панели в панель (вкладка «Перенос»): новая панель выдаёт одноразовый код
// awgmove://, старая шлёт по нему архив на её порт панели. Порт панели обычно — голый HTTP,
// а в архиве ключ подписи, поэтому и запрос, и ответ зашифрованы AES-256-GCM ключом из кода:
// без кода архив не прочесть и не подделать, а верный тег и есть доказательство, что код знают.
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
CodePrefix = "awgmove://"
keyLen = 32
// Разные AAD — ответ нельзя выдать за запрос и наоборот.
aadRequest = "awgmove-v1 request"
aadResponse = "awgmove-v1 response"
)
// Code — что старая панель узнаёт из кода: куда слать и чем шифровать.
type Code struct {
Host string
Port int
Key []byte
}
func NewKey() []byte {
k := make([]byte, keyLen)
rand.Read(k)
return k
}
func (c Code) String() string {
return CodePrefix + b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{
{Key: "v", Val: Version}, {Key: "h", Val: c.Host}, {Key: "p", Val: c.Port}, {Key: "k", Val: b64url.EncodeToString(c.Key)}}))
}
func ParseCode(s string) (Code, error) {
bad := errors.New("код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, CodePrefix) {
return Code{}, bad
}
raw, err := b64url.DecodeString(s[len(CodePrefix):])
if err != nil {
return Code{}, bad
}
v, err := jsjson.Decode(raw)
o, ok := v.(jsjson.Object)
if err != nil || !ok || o.Get2("v") != float64(Version) {
return Code{}, bad
}
host, _ := o.Get2("h").(string)
port, _ := o.Get2("p").(float64)
k, _ := o.Get2("k").(string)
key, err := b64url.DecodeString(k)
if host == "" || len(host) > 253 || strings.ContainsAny(host, "/?#@ ") || port <= 0 || port > 65535 ||
port != float64(int(port)) || err != nil || len(key) != keyLen {
return Code{}, bad
}
return Code{Host: host, Port: int(port), Key: key}, nil
}
// URL — куда слать архив.
func (c Code) URL() string {
h := c.Host
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
h = "[" + h + "]"
}
return "http://" + h + ":" + strconv.Itoa(c.Port) + "/migrate/v1/import"
}
func gcm(key []byte) (cipher.AEAD, error) {
blk, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(blk)
}
func seal(key []byte, aad string, plain []byte) []byte {
g, err := gcm(key)
if err != nil {
panic(err) // ключ всегда 32 байта
}
nonce := make([]byte, g.NonceSize())
rand.Read(nonce)
return g.Seal(nonce, nonce, plain, []byte(aad))
}
var ErrSeal = errors.New("не расшифровать: код не тот или данные повреждены")
func open(key []byte, aad string, box []byte) ([]byte, error) {
g, err := gcm(key)
if err != nil || len(box) < g.NonceSize() {
return nil, ErrSeal
}
plain, err := g.Open(nil, box[:g.NonceSize()], box[g.NonceSize():], []byte(aad))
if err != nil {
return nil, ErrSeal
}
return plain, nil
}
func SealRequest(key, plain []byte) []byte { return seal(key, aadRequest, plain) }
func OpenRequest(key, box []byte) ([]byte, error) { return open(key, aadRequest, box) }
func SealResponse(key, plain []byte) []byte { return seal(key, aadResponse, plain) }
func OpenResponse(key, box []byte) ([]byte, error) { return open(key, aadResponse, box) }
+159
View File
@@ -0,0 +1,159 @@
// Package relay — ретранслятор TCP на сервере, с которого переехала панель (`awg-control
// migrate`). В выданных ссылках sen://, у подключённых нод и у клиентов внешнего API остался
// старый адрес: relay слушает прежние порты подписки, хаба нод и панели и пересылает соединения
// на новую панель как есть — TLS идёт насквозь, ключи и сертификаты у панели прежние. Перед
// данными идёт заголовок PROXY v1 с адресом клиента: панель верит ему только от RELAY_FROM.
package relay
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
)
const dialTimeout = 10 * time.Second
// Route — одна пересылка: локальный порт → адрес новой панели.
type Route struct {
Listen int
Target string // host:port
}
// ParseRoutes — RELAY: «порт=хост:порт» через запятую.
func ParseRoutes(s string) ([]Route, error) {
var out []Route
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
l, t, ok := strings.Cut(part, "=")
port, err := strconv.Atoi(l)
if !ok || err != nil || port <= 0 || port > 65535 {
return nil, fmt.Errorf("relay: неверная пересылка %q (нужно порт=хост:порт)", part)
}
host, tp, err := net.SplitHostPort(t)
if n, perr := strconv.Atoi(tp); err != nil || host == "" || perr != nil || n <= 0 || n > 65535 {
return nil, fmt.Errorf("relay: неверный адрес %q", t)
}
out = append(out, Route{port, t})
}
if len(out) == 0 {
return nil, errors.New("relay: RELAY пуст — пересылать нечего")
}
return out, nil
}
// proxyHeader — строка PROXY v1 для соединения c.
func proxyHeader(c net.Conn) string {
src, ok1 := c.RemoteAddr().(*net.TCPAddr)
dst, ok2 := c.LocalAddr().(*net.TCPAddr)
if !ok1 || !ok2 {
return "PROXY UNKNOWN\r\n"
}
sip, dip := src.IP, dst.IP
fam := "TCP6"
if s4, d4 := sip.To4(), dip.To4(); s4 != nil && d4 != nil {
fam, sip, dip = "TCP4", s4, d4
} else if s4 != nil || d4 != nil {
// Смешанные семейства (v4-mapped на v6-сокете и т. п.) — оба в v6.
sip, dip = sip.To16(), dip.To16()
}
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, sip, dip, src.Port, dst.Port)
}
// pipe — копирование в обе стороны; конец одной стороны закрывает запись в другую.
func pipe(a, b net.Conn) {
var wg sync.WaitGroup
cp := func(dst, src net.Conn) {
defer wg.Done()
io.Copy(dst, src)
if tc, ok := dst.(*net.TCPConn); ok {
tc.CloseWrite()
} else {
dst.Close()
}
}
wg.Add(2)
go cp(a, b)
go cp(b, a)
wg.Wait()
a.Close()
b.Close()
}
func serve(ctx context.Context, ln net.Listener, r Route, errorf func(string, ...any)) {
var d net.Dialer
d.Timeout = dialTimeout
for {
c, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
errorf("relay: :%d: %v", r.Listen, err)
time.Sleep(time.Second)
continue
}
go func() {
up, err := d.DialContext(ctx, "tcp", r.Target)
if err != nil {
errorf("relay: :%d → %s: %v", r.Listen, r.Target, err)
c.Close()
return
}
if _, err := io.WriteString(up, proxyHeader(c)); err != nil {
c.Close()
up.Close()
return
}
pipe(c, up)
}()
}
}
// Run слушает все порты и пересылает до отмены ctx. Порт, который не открылся, — ошибка сразу:
// значит, его ещё держит прежняя панель.
func Run(ctx context.Context, routes []Route, logf, errorf func(string, ...any)) error {
var lns []net.Listener
for _, r := range routes {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(r.Listen))
if err != nil {
for _, l := range lns {
l.Close()
}
return fmt.Errorf("relay: порт %d: %w", r.Listen, err)
}
lns = append(lns, ln)
logf("relay: :%d → %s", r.Listen, r.Target)
}
for i, r := range routes {
go serve(ctx, lns[i], r, errorf)
}
<-ctx.Done()
for _, l := range lns {
l.Close()
}
return ctx.Err()
}
// Main — роль `awg-control relay`: пересылки из RELAY.
func Main(ctx context.Context) error {
routes, err := ParseRoutes(os.Getenv("RELAY"))
if err != nil {
return err
}
logf := func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
errorf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
return Run(ctx, routes, logf, errorf)
}
+102
View File
@@ -0,0 +1,102 @@
package relay
import (
"bufio"
"context"
"io"
"net"
"strconv"
"strings"
"testing"
"time"
)
func TestParseRoutes(t *testing.T) {
r, err := ParseRoutes(" 40123=203.0.113.7:40123, 8443=[2001:db8::1]:9000 ,")
if err != nil || len(r) != 2 || r[0] != (Route{40123, "203.0.113.7:40123"}) || r[1] != (Route{8443, "[2001:db8::1]:9000"}) {
t.Fatalf("ParseRoutes: %v %v", r, err)
}
for _, bad := range []string{"", ",", "40123", "0=h:1", "70000=h:1", "1=h", "1=:5", "1=h:0", "x=h:1"} {
if _, err := ParseRoutes(bad); err == nil {
t.Errorf("ParseRoutes(%q): ожидали ошибку", bad)
}
}
}
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
// Поток идёт насквозь в обе стороны, первой строкой у панели — PROXY с адресом клиента.
func TestRelayForwardsWithProxyHeader(t *testing.T) {
target, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer target.Close()
got := make(chan string, 2)
go func() {
c, err := target.Accept()
if err != nil {
return
}
defer c.Close()
r := bufio.NewReader(c)
hdr, _ := r.ReadString('\n')
got <- hdr
body, _ := io.ReadAll(r)
got <- string(body)
io.WriteString(c, "pong")
}()
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errs := make(chan error, 1)
go func() {
errs <- Run(ctx, []Route{{port, target.Addr().String()}}, func(string, ...any) {}, func(f string, a ...any) { t.Logf(f, a...) })
}()
var cl net.Conn
for range 50 {
if cl, err = net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))); err == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, "ping")
cl.(*net.TCPConn).CloseWrite()
hdr := <-got
src := cl.LocalAddr().(*net.TCPAddr)
if want := "PROXY TCP4 127.0.0.1 127.0.0.1 " + strconv.Itoa(src.Port) + " " + strconv.Itoa(port) + "\r\n"; hdr != want {
t.Errorf("заголовок %q, ожидали %q", hdr, want)
}
if body := <-got; body != "ping" {
t.Errorf("до панели дошло %q", body)
}
cl.SetReadDeadline(time.Now().Add(3 * time.Second))
if back, _ := io.ReadAll(cl); string(back) != "pong" {
t.Errorf("обратно пришло %q", back)
}
// Занятый порт — ошибка сразу: значит, его держит прежняя панель.
busy, _ := net.Listen("tcp", ":0")
defer busy.Close()
if err := Run(context.Background(), []Route{{busy.Addr().(*net.TCPAddr).Port, "127.0.0.1:1"}}, func(string, ...any) {}, t.Logf); err == nil ||
!strings.Contains(err.Error(), "порт") {
t.Errorf("занятый порт: %v", err)
}
cancel()
<-errs
}
+512
View File
@@ -0,0 +1,512 @@
package ui
// Аудит серверов (порт части awg-ui/sub.ts). Периодически core обходит все серверы и ищет
// «мёртвых» пиров: живы на сервере, хотя панель про них не знает. Сообщает и — если включена
// очистка или оператор нажал кнопку — снимает подтверждённых: мёртвых два прохода подряд.
// ghosts — есть в ядре (awg show), но неизвестны awg-ctrl;
// orphans — есть у awg-ctrl в области этой панели, но панели не нужны (sync их не снял);
// key — мастер-ключ пира, если он отозван или удалён.
// Пиры чужой области в отчёт не попадают — считаются в foreign.
import (
"regexp"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
type auditPeer struct {
PubKey string
Owner any // только у orphans
Key any // только у orphans
Online any
LastHandshake any
SeenBefore *bool
orphan bool
}
func (p *auditPeer) json() jsjson.Object {
o := jsjson.Object{{Key: "pub_key", Val: p.PubKey}}
if p.orphan {
o = append(o, jsjson.KV{Key: "owner", Val: p.Owner})
}
o = append(o, jsjson.KV{Key: "online", Val: p.Online}, jsjson.KV{Key: "lastHandshake", Val: p.LastHandshake})
if p.orphan {
o = append(o, jsjson.KV{Key: "key", Val: p.Key})
}
if p.SeenBefore != nil {
o = append(o, jsjson.KV{Key: "seen_before", Val: *p.SeenBefore})
}
return o
}
type auditEntry struct {
ServerID int
Status string // ok | offline | error
GhostsChecked bool // false — awg-ctrl старше /api/audit, «призраков» не видно
Ghosts []*auditPeer
Orphans []*auditPeer
Foreign int
Blocked *int // очистка отказалась снимать столько пиров разом (предохранитель)
}
func peersJSON(ps []*auditPeer) []any {
out := []any{}
for _, p := range ps {
out = append(out, p.json())
}
return out
}
func (e *auditEntry) json() jsjson.Object {
o := jsjson.Object{{Key: "server_id", Val: e.ServerID}, {Key: "status", Val: e.Status}, {Key: "ghosts_checked", Val: e.GhostsChecked},
{Key: "ghosts", Val: peersJSON(e.Ghosts)}, {Key: "orphans", Val: peersJSON(e.Orphans)}, {Key: "foreign", Val: e.Foreign}}
if e.Blocked != nil {
o = append(o, jsjson.KV{Key: "blocked", Val: *e.Blocked})
}
return o
}
type auditReport struct {
At int64
Servers []*auditEntry
}
func (r *auditReport) json() any {
if r == nil {
return nil
}
list := []any{}
for _, e := range r.Servers {
list = append(list, e.json())
}
return jsjson.Object{{Key: "at", Val: r.At}, {Key: "servers", Val: list}}
}
type cleanedPeer struct {
At int64
ServerID int
PubKey string
Kind string // ghost | orphan
Key any // у ghost нет (undefined)
HasKey bool
Auto bool
}
func (c cleanedPeer) json() jsjson.Object {
o := jsjson.Object{{Key: "at", Val: c.At}, {Key: "server_id", Val: c.ServerID}, {Key: "pub_key", Val: c.PubKey}, {Key: "kind", Val: c.Kind}}
if c.HasKey {
o = append(o, jsjson.KV{Key: "key", Val: c.Key})
}
return append(o, jsjson.KV{Key: "auto", Val: c.Auto})
}
// auditSettings — clean: снимать «мёртвых» сами; every_h — период; max — предохранитель.
type auditSettings struct {
Clean bool
EveryH int
Max int
}
func (s auditSettings) json() jsjson.Object {
return jsjson.Object{{Key: "clean", Val: s.Clean}, {Key: "every_h", Val: s.EveryH}, {Key: "max", Val: s.Max}}
}
// ── sub_meta: JSON-значения ─────────────────────────────────────────────────
func (s *Sub) metaGet(key string) any {
var raw string
if s.a.db.QueryRow("SELECT value FROM sub_meta WHERE key = ?", key).Scan(&raw) != nil {
return nil
}
v, err := jsjson.Decode([]byte(raw))
if err != nil {
return nil
}
return v
}
func (s *Sub) metaSet(key string, v any) {
s.a.db.Exec("INSERT INTO sub_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, string(jsjson.MustMarshal(v)))
}
func parsePeer(v any, orphan bool) *auditPeer {
o, _ := v.(jsjson.Object)
p := &auditPeer{PubKey: strOr(o.Get2("pub_key"), ""), Online: o.Get2("online"), LastHandshake: o.Get2("lastHandshake"), orphan: orphan}
if orphan {
p.Owner, p.Key = o.Get2("owner"), o.Get2("key")
}
if b, ok := o.Get2("seen_before").(bool); ok {
p.SeenBefore = &b
}
return p
}
func parseReport(v any) *auditReport {
o, ok := v.(jsjson.Object)
if !ok {
return nil
}
r := &auditReport{At: int64(numOr(o.Get2("at"), 0))}
for _, x := range arr(o.Get2("servers")) {
e := &auditEntry{ServerID: int(numOr(field(x, "server_id"), 0)), Status: strOr(field(x, "status"), "error"),
Foreign: int(numOr(field(x, "foreign"), 0))}
e.GhostsChecked, _ = field(x, "ghosts_checked").(bool)
for _, p := range arr(field(x, "ghosts")) {
e.Ghosts = append(e.Ghosts, parsePeer(p, false))
}
for _, p := range arr(field(x, "orphans")) {
e.Orphans = append(e.Orphans, parsePeer(p, true))
}
if b, ok := field(x, "blocked").(float64); ok {
n := int(b)
e.Blocked = &n
}
r.Servers = append(r.Servers, e)
}
return r
}
func (s *Sub) loadAudit() {
s.lastAudit = parseReport(s.metaGet("audit"))
s.auditSettings = auditSettings{Clean: false, EveryH: 1, Max: 10}
if o, ok := s.metaGet("audit_settings").(jsjson.Object); ok {
if b, ok := o.Get2("clean").(bool); ok {
s.auditSettings.Clean = b
}
if f, ok := o.Get2("every_h").(float64); ok {
s.auditSettings.EveryH = int(f)
}
if f, ok := o.Get2("max").(float64); ok {
s.auditSettings.Max = int(f)
}
}
for _, x := range arr(s.metaGet("audit_cleaned")) {
o, _ := x.(jsjson.Object)
k, hasKey := o.Get("key")
auto, _ := o.Get2("auto").(bool)
s.cleanedLog = append(s.cleanedLog, cleanedPeer{At: int64(numOr(o.Get2("at"), 0)), ServerID: int(numOr(o.Get2("server_id"), 0)),
PubKey: strOr(o.Get2("pub_key"), ""), Kind: strOr(o.Get2("kind"), ""), Key: k, HasKey: hasKey, Auto: auto})
}
}
func (s *Sub) cleanedJSON() []any {
out := []any{}
for _, c := range s.cleanedLog {
out = append(out, c.json())
}
return out
}
// ── Проход ──────────────────────────────────────────────────────────────────
var reOwnerMaster = regexp.MustCompile(`^[^/]+/m(\d+)/`)
func (s *Sub) auditNow(id int) (*auditEntry, error) {
e := &auditEntry{ServerID: id, Status: "ok", GhostsChecked: true}
if !s.a.nodes.Exists(id) || !s.a.nodes.IsOnline(id) {
e.Status, e.GhostsChecked = "offline", false
return e, nil
}
ctrl := s.a.nodes.CtrlFor(id)
// Сначала обычное сведение: отчёт — про то, что не снялось само.
if err := s.reconcileNow(id); err != nil {
return nil, err
}
a, err := ctrl("GET", "/api/audit", nil)
if err != nil {
return nil, err
}
p, err := ctrl("GET", "/api/peers", nil)
if err != nil {
return nil, err
}
fail := func() (*auditEntry, error) {
e.Status, e.GhostsChecked = "error", false
e.Ghosts, e.Orphans = nil, nil
return e, nil
}
if !ok2xx(p.Status) {
return fail()
}
if ok2xx(a.Status) {
for _, g := range arr(field(a.Data, "ghosts")) {
e.Ghosts = append(e.Ghosts, &auditPeer{PubKey: strOr(field(g, "pub_key"), ""), Online: field(g, "online"), LastHandshake: field(g, "lastHandshake")})
}
} else if a.Status == 404 {
e.GhostsChecked = false
} else {
return fail()
}
keep := map[string]bool{}
for _, k := range s.keepOf(id) {
keep[k] = true
}
prefix := s.panelID + "/"
for _, peer := range arr(field(p.Data, "peers")) {
owner, ok := field(peer, "owner").(string)
if !ok {
return nil, errTypeError // peer.owner.startsWith бросил бы TypeError
}
pub := strOr(field(peer, "pub_key"), "")
if !strings.HasPrefix(owner, prefix) {
if strings.Contains(owner, "/") {
e.Foreign++
}
continue
}
if keep[pub] {
continue
}
key := any("(нет в ui.db)")
if m := reOwnerMaster.FindStringSubmatch(owner); m != nil {
if mid, err := strconv.ParseInt(m[1], 10, 64); err == nil && mid <= 9007199254740991 {
if row := s.masterRowByID(s.a.db, mid); row != nil {
if row.RevokedAt.Valid {
key = row.Label + " (удалён)"
} else {
key = row.Label
}
}
}
}
e.Orphans = append(e.Orphans, &auditPeer{PubKey: pub, Owner: owner, Online: field(peer, "online"),
LastHandshake: field(peer, "lastHandshake"), Key: key, orphan: true})
}
return e, nil
}
type jsTypeError struct{}
func (jsTypeError) Error() string { return "TypeError" }
var errTypeError = jsTypeError{}
// audit — под той же блокировкой, что и сведение: пир, которого прямо сейчас добавляет проход, не «призрак».
func (s *Sub) audit(id int) *auditEntry {
l := s.lock(id)
l.Lock()
defer l.Unlock()
e, err := func() (e *auditEntry, err error) {
defer func() {
if recover() != nil {
e, err = nil, errTypeError
}
}()
return s.auditNow(id)
}()
if err != nil || e == nil {
return &auditEntry{ServerID: id, Status: "error"}
}
return e
}
// cleanEntry — снять «мёртвых» сервера: только подтверждённых (seen_before), не больше max за проход.
// «Призраки» — через awg-ctrl (он сам перепроверяет ключи), лишние — обычным DELETE.
func (s *Sub) cleanEntry(e *auditEntry, auto bool) error {
seen := func(ps []*auditPeer) []*auditPeer {
var out []*auditPeer
for _, p := range ps {
if p.SeenBefore != nil && *p.SeenBefore {
out = append(out, p)
}
}
return out
}
ghosts, orphans := seen(e.Ghosts), seen(e.Orphans)
if len(ghosts)+len(orphans) == 0 || e.Status != "ok" {
return nil
}
s.auditMu.Lock()
max := s.auditSettings.Max
s.auditMu.Unlock()
if auto && len(ghosts)+len(orphans) > max {
n := len(ghosts) + len(orphans)
e.Blocked = &n
warnf("sub: очистка сервера #%d пропущена: %d пиров больше предела %d", e.ServerID, n, max)
return nil
}
ctrl := s.a.nodes.CtrlFor(e.ServerID)
var done []cleanedPeer
log := func(p *auditPeer, kind string) {
done = append(done, cleanedPeer{At: now(), ServerID: e.ServerID, PubKey: p.PubKey, Kind: kind, Key: p.Key, HasKey: p.orphan, Auto: auto})
}
if len(ghosts) > 0 {
keys := []any{}
for _, g := range ghosts {
keys = append(keys, g.PubKey)
}
r, err := ctrl("POST", "/api/audit/clean", jsjson.Object{{Key: "pub_keys", Val: keys}})
if err != nil {
return err
}
if ok2xx(r.Status) {
removed := map[string]bool{}
for _, x := range arr(field(r.Data, "removed")) {
if k, ok := x.(string); ok {
removed[k] = true
}
}
for _, g := range ghosts {
if removed[g.PubKey] {
log(g, "ghost")
}
}
}
}
for _, p := range orphans {
r, err := ctrl("DELETE", "/api/peers/"+pubParam(p.PubKey), nil)
if err != nil {
return err
}
if ok2xx(r.Status) || r.Status == 404 {
log(p, "orphan")
} else if gone(r.Status) {
break
}
}
if len(done) == 0 {
return nil
}
cleaned := map[string]bool{}
var short []string
for _, d := range done {
cleaned[d.PubKey] = true
k := d.PubKey
if len(k) > 8 {
k = k[:8]
}
short = append(short, k)
}
drop := func(ps []*auditPeer) []*auditPeer {
var out []*auditPeer
for _, p := range ps {
if !cleaned[p.PubKey] {
out = append(out, p)
}
}
return out
}
e.Ghosts, e.Orphans = drop(e.Ghosts), drop(e.Orphans)
s.auditMu.Lock()
s.cleanedLog = append(done, s.cleanedLog...)
if len(s.cleanedLog) > 100 {
s.cleanedLog = s.cleanedLog[:100]
}
s.metaSet("audit_cleaned", s.cleanedJSON())
s.auditMu.Unlock()
what := "очистка"
if auto {
what = "автоочистка"
}
warnf("sub: %s сервера #%d: снято %d (%s)", what, e.ServerID, len(done), strings.Join(short, ", "))
return nil
}
// cleanLocked — очистка под блокировкой сервера, как и сам аудит.
func (s *Sub) cleanLocked(e *auditEntry, auto bool) {
l := s.lock(e.ServerID)
l.Lock()
defer l.Unlock()
if err := s.cleanEntry(e, auto); err != nil && auto {
warnf("sub: очистка сервера #%d не удалась: %v", e.ServerID, err)
}
}
// auditAll — проход по всем серверам; одновременно идёт не больше одного (повторный вызов ждёт
// текущий). auto — плановый: очищает сам, если включено. Ручной только смотрит.
func (s *Sub) auditAll(auto bool) {
s.auditMu.Lock()
if ch := s.auditing; ch != nil {
s.auditMu.Unlock()
<-ch
return
}
ch := make(chan struct{})
s.auditing = ch
prev := s.lastAudit
s.auditMu.Unlock()
defer func() {
s.auditMu.Lock()
s.auditing = nil
s.auditMu.Unlock()
close(ch)
}()
ids := s.a.nodes.IDs()
report := &auditReport{At: now(), Servers: make([]*auditEntry, len(ids))}
done := make(chan struct{}, len(ids))
for i, id := range ids {
go func() { report.Servers[i] = s.audit(id); done <- struct{}{} }()
}
for range ids {
<-done
}
s.auditMu.Lock()
clean := s.auditSettings.Clean
s.auditMu.Unlock()
for _, e := range report.Servers {
was := map[string]bool{}
if prev != nil {
for _, x := range prev.Servers {
if x.ServerID == e.ServerID {
for _, p := range append(append([]*auditPeer{}, x.Ghosts...), x.Orphans...) {
was[p.PubKey] = true
}
break
}
}
}
for _, p := range append(append([]*auditPeer{}, e.Ghosts...), e.Orphans...) {
b := was[p.PubKey]
p.SeenBefore = &b
}
if auto && clean {
s.cleanLocked(e, true)
}
}
s.auditMu.Lock()
s.lastAudit = report
s.metaSet("audit", report.json())
s.auditMu.Unlock()
for _, e := range report.Servers {
if e.Status == "error" {
warnf("sub: аудит сервера #%d не удался", e.ServerID)
}
if n := len(e.Ghosts) + len(e.Orphans); n > 0 {
online := 0
for _, p := range append(append([]*auditPeer{}, e.Ghosts...), e.Orphans...) {
if truthy(p.Online) {
online++
}
}
warnf("sub: аудит сервера #%d: мёртвых пиров %d (в ядре без записи: %d, лишних у awg-ctrl: %d, онлайн: %d)",
e.ServerID, n, len(e.Ghosts), len(e.Orphans), online)
}
}
}
// cleanNow — ручная очистка подтверждённых «мёртвых» по последнему отчёту.
func (s *Sub) cleanNow() {
s.auditMu.Lock()
rep := s.lastAudit
s.auditMu.Unlock()
if rep == nil {
return
}
for _, e := range rep.Servers {
s.cleanLocked(e, false)
}
s.auditMu.Lock()
s.metaSet("audit", rep.json())
s.auditMu.Unlock()
}
func (s *Sub) auditState() jsjson.Object {
s.auditMu.Lock()
defer s.auditMu.Unlock()
return jsjson.Object{{Key: "enabled", Val: s.auditOn}, {Key: "settings", Val: s.auditSettings.json()},
{Key: "report", Val: s.lastAudit.json()}, {Key: "cleaned", Val: s.cleanedJSON()}}
}
+104
View File
@@ -0,0 +1,104 @@
package ui
import (
"errors"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
// «Серверы для всех»: add — у каждого живого ключа, remove — ни у кого, остальное не трогается;
// пиры снятых серверов — в надгробия, новые ключи получают набор по умолчанию.
func TestBulkServers(t *testing.T) {
p := newMovePanel(t, "off", "40500")
s, db := p.s, p.a.db
if err := p.a.initAPIKeys(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'A', 'x', 1), (2, 'B', 'x', 1), (3, 'C', 'x', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k1', x'01', 1), (2, 'u2', 'k2', x'02', 1), (3, 'u3', 'k3', x'03', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (4, 'u4', 'k4', x'04', 1, 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 1), (2, 1), (2, 2), (3, 2), (4, 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 2, 'd', 'W', 'A', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 1, 'P1', '10.0.0.2', 'K'), (5, 2, 'P2', '10.0.1.2', 'K')",
"INSERT INTO api_keys (id, label, key_hash, prefix, created_at) VALUES (1, 'Бот', 'h1', 'p1', 1), (2, 'Всё', 'h2', 'p2', 1)",
"INSERT INTO api_key_servers (key_id, server_id) VALUES (1, 1), (1, 2), (2, 1), (2, 2), (2, 3)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(q, err)
}
}
servers := func() string {
var out []string
for _, id := range []int64{1, 2, 3, 4} {
out = append(out, string(jsjson.MustMarshal(intsJSON(s.serverIDsOf(db, id)))))
}
return strings.Join(out, " ")
}
body := func(kv ...any) jsjson.Object {
o := jsjson.Object{}
for i := 0; i < len(kv); i += 2 {
o = append(o, jsjson.KV{Key: kv[i].(string), Val: kv[i+1]})
}
return o
}
f := func(ids ...float64) []any {
out := []any{}
for _, x := range ids {
out = append(out, x)
}
return out
}
status := func(err error) int {
var ae *apiError
if errors.As(err, &ae) {
return ae.status
}
return 0
}
out, err := s.bulkServers(body("add", f(3), "remove", f(2), "dry_run", true))
if err != nil {
t.Fatal(err)
}
if got := string(jsjson.MustMarshal(out)); got != `{"changed":3,"total":3,"hidden":[{"id":1,"label":"Бот","keys":3}]}` {
t.Errorf("предпросмотр: %s", got)
}
if got := servers(); got != "[1] [1,2] [2] [1]" {
t.Errorf("предпросмотр изменил ключи: %s", got)
}
if _, err := s.bulkServers(body("remove", f(1, 2))); status(err) != 409 {
t.Errorf("ключи без серверов: %v", err)
}
if _, err := s.bulkServers(body("add", f(2), "remove", f(2))); status(err) != 400 {
t.Errorf("и у всех, и ни у кого: %v", err)
}
if _, err := s.bulkServers(body("add", f(9))); status(err) != 400 {
t.Errorf("несуществующий сервер: %v", err)
}
if _, err := s.bulkServers(body("add", f(3), "remove", f(2), "defaults", f(3))); err != nil {
t.Fatal(err)
}
if got := servers(); got != "[1,3] [1,3] [3] [1]" {
t.Errorf("после применения: %s (удаляемый ключ трогать нельзя)", got)
}
var n int
db.QueryRow("SELECT COUNT(*) FROM peer_tombstones WHERE server_id = 2 AND pub_key = 'P2'").Scan(&n)
if n != 1 {
t.Error("пир снятого сервера не в надгробиях")
}
db.QueryRow("SELECT COUNT(*) FROM device_peers WHERE device_id = 5 AND server_id = 3").Scan(&n)
if n != 1 {
t.Error("устройство не получило заявку на новом сервере")
}
m, err := s.createMaster(body("label", "Новый"), nil)
if err != nil || string(jsjson.MustMarshal(m.Get2("servers"))) != "[3]" {
t.Errorf("новый ключ: %v %v", m, err)
}
if _, err := s.bulkServers(body("defaults", nil)); err != nil || s.defaultServers() != nil {
t.Errorf("сброс набора по умолчанию: %v %v", s.defaultServers(), err)
}
}
+122
View File
@@ -0,0 +1,122 @@
package ui
import (
"io"
"log"
"math"
"net/http"
"strconv"
"strings"
"unicode/utf16"
)
// jsToNumber — Number(s) для строки: пробелы по краям, пусто → 0, 0x/0o/0b, Infinity;
// всё прочее нечисловое — NaN.
func jsToNumber(s string) float64 {
t := strings.TrimSpace(s)
if t == "" {
return 0
}
switch t {
case "Infinity", "+Infinity":
return math.Inf(1)
case "-Infinity":
return math.Inf(-1)
}
if len(t) > 2 && t[0] == '0' {
base := 0
switch t[1] {
case 'x', 'X':
base = 16
case 'o', 'O':
base = 8
case 'b', 'B':
base = 2
}
if base != 0 {
n, err := strconv.ParseUint(t[2:], base, 64)
if err != nil {
return math.NaN()
}
return float64(n)
}
}
for _, r := range t {
if !(r >= '0' && r <= '9' || r == '.' || r == 'e' || r == 'E' || r == '+' || r == '-') {
return math.NaN()
}
}
f, err := strconv.ParseFloat(t, 64)
if err != nil {
return math.NaN()
}
return f
}
// jsToNumberAny — Number(v) для значения из JSON.
func jsToNumberAny(v any) float64 {
switch x := v.(type) {
case nil:
return 0
case bool:
if x {
return 1
}
return 0
case float64:
return x
case string:
return jsToNumber(x)
}
return math.NaN()
}
// encodeURIComponent — как в JS: не трогает A-Z a-z 0-9 - _ . ! ~ * ' ( ).
func encodeURIComponent(s string) string {
const hexd = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || strings.IndexByte("-_.!~*'()", c) >= 0 {
b.WriteByte(c)
} else {
b.WriteByte('%')
b.WriteByte(hexd[c>>4])
b.WriteByte(hexd[c&15])
}
}
return b.String()
}
func readAll(resp *http.Response) ([]byte, error) { return io.ReadAll(resp.Body) }
// utf16Len — длина строки в JS (кодовые единицы UTF-16).
func utf16Len(s string) int {
n := 0
for _, r := range s {
if r >= 0x10000 {
n += 2
} else {
n++
}
}
return n
}
// lenOK — `{min,max}` в JS-регулярке над строкой целиком: длина в единицах UTF-16.
func lenOK(s string, min, max int) bool {
n := utf16Len(s)
return n >= min && n <= max
}
// sliceUTF16 — s.slice(0, n) в JS. Разрезанная суррогатная пара при записи в SQLite
// превращается в U+FFFD — так же делаем и здесь.
func sliceUTF16(s string, n int) string {
units := utf16.Encode([]rune(s))
if len(units) <= n {
return s
}
return string(utf16.Decode(units[:n]))
}
func quietLog() *log.Logger { return log.New(io.Discard, "", 0) }
+484
View File
@@ -0,0 +1,484 @@
package ui
// Prometheus: GET /metrics на ядре (порт awg-ui/metrics.ts). Метрики ядра и всех нод
// собираются здесь же — у нод через уже открытый WSS. Доступ — отдельный токен «только чтение
// метрик»; он не открывает ни /api/v1, ни панель.
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"math"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
scrapeDeadline = 5 * time.Second
touchEveryS = 60 // last_used пишем не чаще раза в минуту, а не на каждый скрейп
)
func hashToken(t string) []byte {
s := sha256.Sum256([]byte(t))
return s[:]
}
type Metrics struct {
a *App
perPeer bool
localFile string
mu sync.Mutex
localMod time.Time
localSeen bool
localHash []byte
}
func newMetrics(a *App) (*Metrics, error) {
m := &Metrics{
a: a,
// Пер-пировые серии (peer = внутренний IP, name = имя пользователя) выключены: это и
// приватность, и кардинальность. Включается явно.
perPeer: strings.ToLower(envOr("METRICS_PEERS", "off")) == "on",
localFile: envOr("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"),
}
_, err := a.db.Exec(`
CREATE TABLE IF NOT EXISTS metrics_token (
id INTEGER PRIMARY KEY CHECK (id = 1),
token_hash BLOB NOT NULL,
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER
)
`)
return m, err
}
var reHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`)
// localTokenHash — токен встроенного Prometheus (`awg-ctrl monitor install`): здесь его хэш.
// Файл перечитывается по mtime: установка не требует перезапуска панели.
func (m *Metrics) localTokenHash() []byte {
m.mu.Lock()
defer m.mu.Unlock()
st, err := os.Stat(m.localFile)
if err != nil {
m.localSeen, m.localHash = false, nil
return nil
}
if !m.localSeen || !st.ModTime().Equal(m.localMod) {
raw, err := os.ReadFile(m.localFile)
if err != nil {
m.localSeen, m.localHash = false, nil
return nil
}
m.localSeen, m.localMod, m.localHash = true, st.ModTime(), nil
if h := strings.TrimSpace(string(raw)); reHex64.MatchString(h) {
m.localHash, _ = hex.DecodeString(h)
}
}
return m.localHash
}
type tokenRow struct {
hash []byte
prefix string
createdAt int64
lastUsed sql.NullInt64
}
func (m *Metrics) token() *tokenRow {
t := &tokenRow{}
if m.a.db.QueryRow("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&t.hash, &t.prefix, &t.createdAt, &t.lastUsed) != nil {
return nil
}
return t
}
func (m *Metrics) view() jsjson.Object {
if t := m.token(); t != nil {
return jsjson.Object{{Key: "exists", Val: true}, {Key: "prefix", Val: t.prefix}, {Key: "created_at", Val: t.createdAt},
{Key: "last_used", Val: nullInt(t.lastUsed)}, {Key: "per_peer", Val: m.perPeer}}
}
return jsjson.Object{{Key: "exists", Val: false}, {Key: "per_peer", Val: m.perPeer}}
}
// router — управление токеном (роуты панели, за JWT). Выпуск и перевыпуск — одно и то же:
// старый токен перестаёт работать. Значение отдаётся один раз.
func (m *Metrics) router() *Router {
rt := newRouter()
rt.Get("/", func(c *Ctx) bool { return c.JSON(200, m.view()) })
rt.Post("/", func(c *Ctx) bool {
b := make([]byte, 24)
rand.Read(b)
tok := "awgm_" + b64url.EncodeToString(b)
m.a.db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)",
hashToken(tok), tok[:13]+"…", now())
return c.JSON(201, append(m.view(), jsjson.KV{Key: "token", Val: tok}))
})
rt.Delete("/", func(c *Ctx) bool {
m.a.db.Exec("DELETE FROM metrics_token WHERE id = 1")
return c.JSON(200, m.view())
})
return rt
}
func writeText(c *Ctx, status int, ctype, body string) bool {
c.W.Header().Set("Content-Type", ctype)
c.W.WriteHeader(status)
c.W.Write([]byte(body))
return false
}
// requireToken — нет токена или он неверный — одинаковый 401: не подсказываем, выключены
// метрики или токен не тот.
func (m *Metrics) requireToken(c *Ctx) bool {
given := bearer(c.R)
row := m.token()
var gh []byte
if strings.HasPrefix(given, "awgm_") {
gh = hashToken(given)
}
panelOK := row != nil && gh != nil && len(row.hash) == len(gh) && subtle.ConstantTimeCompare(gh, row.hash) == 1
local := m.localTokenHash()
localOK := gh != nil && local != nil && subtle.ConstantTimeCompare(gh, local) == 1
if !panelOK && !localOK {
c.W.Header().Set("WWW-Authenticate", "Bearer")
return writeText(c, 401, "text/plain; charset=utf-8", "unauthorized\n")
}
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
if t := now(); panelOK && (!row.lastUsed.Valid || row.lastUsed.Int64 == 0 || t-row.lastUsed.Int64 >= touchEveryS) {
m.a.db.Exec("UPDATE metrics_token SET last_used = ? WHERE id = 1", t)
}
return true
}
// ── Экспозиция ──────────────────────────────────────────────────────────────
type label struct {
k string
v any
}
type sample struct {
labels []label
v any // число; не число и не конечное — 0
}
type exposition struct {
b bytes.Buffer
seen map[string]bool
}
var (
escLabel = strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
escHelp = strings.NewReplacer(`\`, `\\`, "\n", `\n`)
)
// labelString — String(v) для значения метки.
func labelString(v any) string {
switch x := v.(type) {
case string:
return x
case int:
return jsjson.Number(float64(x))
case int64:
return jsjson.Number(float64(x))
case float64:
return jsjson.Number(x)
case bool:
if x {
return "true"
}
return "false"
case nil:
return "undefined"
}
return "[object Object]"
}
func (e *exposition) add(name, typ, help string, samples []sample) {
if len(samples) == 0 {
return
}
if !e.seen[name] {
e.seen[name] = true
e.b.WriteString("# HELP " + name + " " + escHelp.Replace(help) + "\n# TYPE " + name + " " + typ + "\n")
}
for _, s := range samples {
e.b.WriteString(name)
if len(s.labels) > 0 {
parts := make([]string, len(s.labels))
for i, l := range s.labels {
parts[i] = l.k + `="` + escLabel.Replace(labelString(l.v)) + `"`
}
e.b.WriteString("{" + strings.Join(parts, ",") + "}")
}
v := 0.0
switch x := s.v.(type) {
case float64:
v = x
case int:
v = float64(x)
case int64:
v = float64(x)
}
if math.IsNaN(v) || math.IsInf(v, 0) {
v = 0
}
e.b.WriteString(" " + jsjson.Number(v) + "\n")
}
}
func b2f(b bool) float64 {
if b {
return 1
}
return 0
}
// scrapeServer — один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
func (m *Metrics) scrapeServer(s serverInfo) any {
if s.Pending || !m.a.nodes.IsOnline(s.ID) {
return nil
}
ch := make(chan any, 1)
go func() {
r, err := m.a.nodes.CtrlFor(s.ID)("GET", "/api/metrics", nil)
if err != nil || r.Status != 200 {
ch <- nil
return
}
ch <- r.Data
}()
select {
case d := <-ch:
return d
case <-time.After(scrapeDeadline):
return nil
}
}
func (m *Metrics) render() string {
t0 := time.Now()
list := m.a.nodes.List()
data := make([]any, len(list))
var wg sync.WaitGroup
for i, s := range list {
wg.Add(1)
go func() { defer wg.Done(); data[i] = m.scrapeServer(s) }()
}
wg.Wait()
out := &exposition{seen: map[string]bool{}}
L := func(s serverInfo, extra ...label) []label {
return append([]label{{"server", s.Name}, {"server_id", s.ID}}, extra...)
}
// Серии только тех серверов, чьи метрики получены.
per := func(fn func(s serverInfo, d any) []sample) []sample {
var res []sample
for i, s := range list {
if truthy(data[i]) {
res = append(res, fn(s, data[i])...)
}
}
return res
}
one := func(fn func(d any) any) func(serverInfo, any) []sample {
return func(s serverInfo, d any) []sample { return []sample{{L(s), fn(d)}} }
}
or0 := func(v any) any {
if v == nil {
return 0.0
}
return v
}
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", []sample{{[]label{{"version", m.a.version}}, 1.0}})
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", []sample{{nil, float64(len(list))}})
var online, scrapeOK, info []sample
for i, s := range list {
online = append(online, sample{L(s), b2f(s.Online)})
scrapeOK = append(scrapeOK, sample{L(s), b2f(truthy(data[i]))})
if !s.Pending {
gen, module, tools := any(""), any(""), any("")
if s.Health != nil {
gen, module, tools = s.Health.Gen, s.Health.Module, s.Health.Tools
}
ver := s.Version
if ver == nil {
ver = ""
}
info = append(info, sample{L(s, label{"version", ver}, label{"gen", gen}, label{"module", module}, label{"tools", tools}), 1.0})
}
}
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", online)
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", scrapeOK)
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.", info)
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per(one(func(d any) any { return b2f(truthy(field(d, "up"))) })))
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per(one(func(d any) any { return or0(field(d, "peers", "total")) })))
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per(one(func(d any) any { return or0(field(d, "peers", "online")) })))
// Сумма по пирам — gauge, а не counter: при удалении пира сумма уменьшается.
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "rx_bytes")) })))
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "tx_bytes")) })))
for _, x := range [][3]string{
{"rx_bytes", "receive_bytes", "байт"}, {"tx_bytes", "transmit_bytes", "байт"},
{"rx_packets", "receive_packets", "пакетов"}, {"tx_packets", "transmit_packets", "пакетов"},
{"rx_dropped", "receive_dropped", "потерянных пакетов"}, {"tx_dropped", "transmit_dropped", "потерянных пакетов"},
{"rx_errors", "receive_errors", "ошибок"}, {"tx_errors", "transmit_errors", "ошибок"},
} {
out.add("awg_link_"+x[1]+"_total", "counter", "Интерфейс awg1: "+x[2]+" ("+x[0]+").", per(one(func(d any) any { return or0(field(d, "link", x[0])) })))
}
// Нагрузка хоста. CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
per(func(s serverInfo, d any) []sample {
cpu, _ := field(d, "host", "cpu").(jsjson.Object)
var res []sample
for _, kv := range cpu {
res = append(res, sample{L(s, label{"mode", kv.Key}), jsToNumberAny(kv.Val)})
}
return res
}))
hostOne := func(fn func(h any) any) func(serverInfo, any) []sample {
return func(s serverInfo, d any) []sample {
h := field(d, "host")
if !truthy(h) {
return nil
}
return []sample{{L(s), fn(h)}}
}
}
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per(hostOne(func(h any) any { return or0(field(h, "cores")) })))
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "total")) })))
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "available")) })))
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).", per(func(s serverInfo, d any) []sample {
h := field(d, "host")
if !truthy(h) {
return nil
}
load := arr(field(h, "load"))
var res []sample
for i, p := range []string{"1m", "5m", "15m"} {
var v any = 0.0
if i < len(load) && load[i] != nil {
v = load[i]
}
res = append(res, sample{L(s, label{"period", p}), v})
}
return res
}))
// Деление канала (CAKE)
sh := func(fn func(x any) any) []sample {
return per(func(s serverInfo, d any) []sample {
x := field(d, "shaping")
if !truthy(x) {
return nil
}
return []sample{{L(s), fn(x)}}
})
}
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "settings", "enabled"))) }))
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "active"))) }))
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "congested"))) }))
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(func(x any) any {
if h, ok := field(x, "heavy").([]any); ok {
return float64(len(h))
}
return 0.0
}))
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(func(x any) any { return or0(field(x, "devices")) }))
// dirs — значение nil (undefined) серию не даёт.
dirs := func(fn func(x any, dir string) any) []sample {
return per(func(s serverInfo, d any) []sample {
x := field(d, "shaping")
if !truthy(x) {
return nil
}
var res []sample
for _, dir := range []string{"down", "up"} {
if v := fn(x, dir); v != nil {
res = append(res, sample{L(s, label{"direction", dir}), v})
}
}
return res
})
}
mbit := func(v any) any {
if v == nil {
return math.NaN() // undefined * 1e6
}
return jsToNumberAny(v) * 1e6
}
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs(func(x any, dir string) any {
if dir == "down" {
return mbit(field(x, "settings", "downMbit"))
}
return mbit(field(x, "settings", "upMbit"))
}))
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs(func(x any, dir string) any {
if dir == "down" {
return mbit(field(x, "rateDown"))
}
return mbit(field(x, "rateUp"))
}))
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "drops") }))
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "backlog") }))
// Блокировка BitTorrent
tb := func(fn func(x any) any) []sample {
return per(func(s serverInfo, d any) []sample {
x := field(d, "torrent")
if !truthy(x) {
return nil
}
return []sample{{L(s), fn(x)}}
})
}
out.add("awg_torrent_block_enabled", "gauge", "Блокировка BitTorrent включена в настройках (1/0).", tb(func(x any) any { return b2f(truthy(field(x, "settings", "enabled"))) }))
out.add("awg_torrent_block_active", "gauge", "nft-таблица блокировки реально стоит (1/0).", tb(func(x any) any { return b2f(truthy(field(x, "active"))) }))
out.add("awg_torrent_blocked_total", "counter", "Заблокированных попыток торрент-соединений с последнего применения правил.", tb(func(x any) any { return or0(field(x, "hits")) }))
out.add("awg_torrent_devices", "gauge", "Устройств, пытавшихся качать торренты за последние 10 минут.", tb(func(x any) any { return float64(len(arr(field(x, "seen")))) }))
if m.perPeer {
peers := func(fn func(p any) any) []sample {
return per(func(s serverInfo, d any) []sample {
var res []sample
for _, p := range arr(field(d, "list")) {
name := field(p, "name")
if name == nil {
name = ""
}
res = append(res, sample{L(s, label{"peer", field(p, "ip")}, label{"name", name}, label{"kind", field(p, "kind")}), fn(p)})
}
return res
})
}
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "online"))) }))
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(func(p any) any { return b2f(field(p, "priority") == "background") }))
out.add("awg_peer_torrent", "gauge", "Пир пытался открыть торрент-соединение за последние 10 минут (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "torrent"))) }))
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(func(p any) any { return field(p, "rx") }))
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(func(p any) any { return field(p, "tx") }))
}
var masters, devices int64
m.a.db.QueryRow("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").Scan(&masters)
m.a.db.QueryRow("SELECT COUNT(*) AS n FROM devices").Scan(&devices)
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", []sample{{nil, masters}})
out.add("awg_devices", "gauge", "Устройств sen://.", []sample{{nil, devices}})
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", []sample{{nil, time.Since(t0).Seconds()}})
return out.b.String()
}
func (m *Metrics) handler(c *Ctx) bool {
return writeText(c, 200, "text/plain; version=0.0.4; charset=utf-8", m.render())
}
+454
View File
@@ -0,0 +1,454 @@
package ui
// Вкладка «Перенос»: панель переезжает с сервера «панель + VPN» на отдельную панель без VPN
// прямо из интерфейса, без архива в руках. Те же шаги, что `awg-control migrate` (общий код —
// internal/migrate), только архив идёт из панели в панель:
//
// новая: POST /ui/migrate/code → одноразовый код awgmove:// (адрес, порт панели, ключ)
// старая: POST /ui/migrate/export → замораживает /sub/v1, шлёт зашифрованный архив на
// POST /migrate/v1/import новой панели
// новая: раскладывает архив, заводит ноду #0, отвечает join-строкой и перезапускается
// старая: пишет PANEL_MOVED и запускает `awg-control join` — становится нодой #0 с ретранслятором
//
// Перезапуск и join делает CLI в отдельном процессе: панель останавливает сама себя.
import (
"bytes"
"database/sql"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
const (
moveCodeTTL = time.Hour
moveMaxFails = 5
moveMaxBundle = 1 << 30
moveTimeout = 10 * time.Minute
)
// Состояние переноса на этой панели (в памяти: перезапуск его сбрасывает, и так и надо).
const (
moveIdle = ""
moveSending = "sending" // старая: архив в пути, /sub/v1 заморожен
moveMoved = "moved" // старая: новая приняла, сейчас будет join
moveUncertain = "uncertain" // старая: архив ушёл, а ответа нет — новая могла его принять
moveImported = "imported" // новая: архив принят, сейчас перезапуск
)
type Migration struct {
a *App
envFile string // cli.env: сюда пишутся порты, PANEL_MOVED, RELAY_FROM
run func(args ...string) // `awg-control …` отдельным процессом; в тестах — заглушка
mu sync.Mutex
key []byte // новая: ключ действующего кода
host string
expires time.Time
fails int
state string
errText string
target string // старая: адрес новой панели
}
func newMigration(a *App) *Migration {
m := &Migration{a: a, envFile: os.Getenv("CLI_ENV_FILE")}
m.run = m.spawn
return m
}
func (a *App) filePath(env, name string) string {
if v := os.Getenv(env); v != "" {
return v
}
return filepath.Join(a.baseDir, name)
}
// paths — где awg-ui держит файлы из архива (те же правила, что при их загрузке).
func (m *Migration) paths() migrate.Paths {
return migrate.PathsFrom(func(env string) string {
for _, f := range migrate.Files {
if f.Env == env {
return m.a.filePath(env, f.Name)
}
}
return ""
})
}
// canExport / canAccept — "" или причина, почему нельзя.
func (m *Migration) canExport() string {
switch {
case m.envFile == "":
return "Панель запущена не через awg-control — перенос недоступен"
case os.Getenv("CORE_HOST") != "":
return "Этот сервер уже нода другой панели"
case !m.a.nodes.local:
return "У этой панели нет своего VPN: переносить её как сервер-ноду нечего"
}
return ""
}
func (m *Migration) canAccept() string {
switch {
case m.envFile == "":
return "Панель запущена не через awg-control — перенос недоступен"
case m.a.nodes.local:
return "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
case !m.a.nodes.hubReady():
return "Не настроен приём нод (NODE_PORT): старый сервер подключится сюда нодой"
case migrate.Count(m.a.db) > 0:
return "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую"
}
return ""
}
func (m *Migration) router() *Router {
rt := newRouter()
rt.Get("/", m.status)
rt.Post("/code", m.newCode)
rt.Delete("/code", func(c *Ctx) bool {
m.mu.Lock()
m.key = nil
m.mu.Unlock()
return m.status(c)
})
rt.Post("/export", m.export)
rt.Post("/cancel", m.cancel)
rt.Delete("/retire", func(c *Ctx) bool {
if err := envfile.Set(m.envFile, nil, "MOVED_IN_AT"); err != nil {
return c.Error(500, err.Error())
}
os.Unsetenv("MOVED_IN_AT")
return m.status(c)
})
return rt
}
func (m *Migration) status(c *Ctx) bool {
m.mu.Lock()
state, errText, target := m.state, m.errText, m.target
var code any
if m.key != nil && time.Now().Before(m.expires) {
code = jsjson.Object{{Key: "code", Val: migrate.Code{Host: m.host, Port: m.a.port, Key: m.key}.String()},
{Key: "expires_in", Val: int(time.Until(m.expires).Seconds())}}
}
m.mu.Unlock()
o := jsjson.Object{{Key: "state", Val: state}, {Key: "error", Val: orNil(errText)}, {Key: "target", Val: orNil(target)}, {Key: "code", Val: code}}
// После приёма база закрыта до перезапуска — что можно, уже не считаем.
if state != moveImported {
o = append(o, jsjson.KV{Key: "export", Val: orNil(m.canExport())}, jsjson.KV{Key: "accept", Val: orNil(m.canAccept())},
jsjson.KV{Key: "retire", Val: m.retire()})
}
return c.JSON(200, o)
}
// ── Новая панель ────────────────────────────────────────────────────────────
// retire — можно ли выключить старый сервер (ноду #0 с ретранслятором): кто из клиентов и нод
// уже знает новый адрес панели. Клиент узнаёт его из endpoints любого конфига после переноса,
// нода — из ready (core_ok). Ссылки sen:// без устройств ещё никем не открыты: в них старый
// адрес, их выдают заново. nil — панель ничего не принимала (или отметку убрали).
func (m *Migration) retire() any {
since, _ := strconv.ParseInt(os.Getenv("MOVED_IN_AT"), 10, 64)
if since == 0 || m.a.nodes.local {
return nil
}
home := m.a.nodes.home()
var total, fresh, unused int
m.a.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(d.last_seen >= ?), 0) FROM devices d
JOIN master_keys k ON k.id = d.master_id WHERE k.revoked_at IS NULL`, since).Scan(&total, &fresh)
m.a.db.QueryRow(`SELECT COUNT(*) FROM master_keys k WHERE k.revoked_at IS NULL
AND NOT EXISTS (SELECT 1 FROM devices d WHERE d.master_id = k.id)`).Scan(&unused)
nodes := []any{}
if rows, err := m.a.db.Query("SELECT id, name, core_ack FROM nodes WHERE id != 0 AND pub IS NOT NULL ORDER BY id"); err == nil {
for rows.Next() {
var id int64
var name string
var ack sql.NullString
if rows.Scan(&id, &name, &ack) == nil {
nodes = append(nodes, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name},
{Key: "online", Val: m.a.nodes.IsOnline(int(id))}, {Key: "ok", Val: home != "" && ack.String == home}})
}
}
rows.Close()
}
return jsjson.Object{{Key: "since", Val: since}, {Key: "home", Val: orNil(home)},
{Key: "old", Val: jsjson.Object{{Key: "exists", Val: m.a.nodes.Exists(0)}, {Key: "online", Val: m.a.nodes.IsOnline(0)}}},
{Key: "devices", Val: jsjson.Object{{Key: "total", Val: total}, {Key: "fresh", Val: fresh}}},
{Key: "unused_links", Val: unused}, {Key: "nodes", Val: nodes}}
}
func (m *Migration) newCode(c *Ctx) bool {
if why := m.canAccept(); why != "" {
return c.Error(409, why)
}
host, _ := c.Obj().Get2("host").(string)
if h := strings.TrimSpace(os.Getenv("SUB_HOST")); h != "" {
host = h
}
if !reHost.MatchString(host) {
return c.Error(400, "Не удалось определить адрес панели — задайте SUB_HOST")
}
m.mu.Lock()
m.key, m.host, m.expires, m.fails = migrate.NewKey(), host, time.Now().Add(moveCodeTTL), 0
m.mu.Unlock()
logf("migrate: выдан код переноса (действует час)")
return m.status(c)
}
// importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM).
// Ответ с ошибкой — открытым текстом и только пока ничего не тронуто.
func (m *Migration) importHandler(c *Ctx) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.key == nil || time.Now().After(m.expires) {
return c.Error(404, "Новая панель не ждёт переноса: выдайте код заново")
}
box, err := io.ReadAll(http.MaxBytesReader(c.W, c.R.Body, moveMaxBundle))
if err != nil {
return c.Error(413, "Архив слишком большой")
}
plain, err := migrate.OpenRequest(m.key, box)
if err != nil {
if m.fails++; m.fails >= moveMaxFails {
m.key = nil
}
warnf("migrate: архив не расшифрован ip=%s", c.remoteIP())
return c.Error(403, "Код не подходит")
}
key := m.key
m.key = nil // код одноразовый
if why := m.canAccept(); why != "" {
return c.Error(409, why)
}
b, err := migrate.Read(bytes.NewReader(plain))
if err != nil {
return c.Error(400, err.Error())
}
defer b.Close()
host := m.host
plan, err := migrate.NewPlan(b, func(k string) int {
switch k {
case "SUB_PORT":
return m.a.sub.port
case "NODE_PORT":
return m.a.nodes.port
}
return 0
}, m.a.port, host)
if err != nil {
return c.Error(409, err.Error())
}
if ip := net.ParseIP(c.remoteIP()); ip != nil {
plan.TrustRelay(ip.String())
}
// Дальше файлы меняются: база закрывается (WAL сливается в файл до того, как его отложат).
paths := m.paths()
fail := func(err error) bool {
m.state, m.errText = moveImported, "Перенос прерван: "+err.Error()+". Прежние файлы — в *.bak-<время>."
warnf("migrate: %v", err)
return c.Error(500, m.errText)
}
m.a.db.Close()
secret := migrate.NewKey()[:16]
if err := migrate.Install(b, paths); err != nil {
return fail(err)
}
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
return fail(err)
}
pin, err := migrate.CertPin(paths["node_tls.crt"])
if err != nil {
return fail(fmt.Errorf("сертификат хаба: %w", err))
}
if err := envfile.Set(m.envFile, plan.Env); err != nil {
return fail(err)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
m.state = moveImported
logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP())
m.run("restart")
resp := jsjson.MustMarshal(jsjson.Object{{Key: "join", Val: join}})
c.W.Header().Set("Content-Type", "application/octet-stream")
c.W.WriteHeader(200)
c.W.Write(migrate.SealResponse(key, resp))
return false
}
// ── Старая панель ───────────────────────────────────────────────────────────
func (m *Migration) export(c *Ctx) bool {
if why := m.canExport(); why != "" {
return c.Error(409, why)
}
codeStr, _ := c.Obj().Get2("code").(string)
code, err := migrate.ParseCode(codeStr)
if err != nil {
return c.Error(400, err.Error())
}
m.mu.Lock()
if m.state != moveIdle {
m.mu.Unlock()
return c.Error(409, "Перенос уже идёт")
}
m.state, m.errText = moveSending, ""
m.mu.Unlock()
end := func(state, errText string) {
m.mu.Lock()
m.state, m.errText = state, errText
m.mu.Unlock()
m.a.sub.frozen.Store(state != moveIdle)
}
// Пока архив в пути, подписка отвечает «недоступна» (клиенты держат туннель и повторят):
// регистрация или смена ключа сейчас остались бы только в старой базе.
m.a.sub.frozen.Store(true)
var buf bytes.Buffer
if err := migrate.Write(&buf, m.a.db, m.paths(), m.exportMeta()); err != nil {
end(moveIdle, "")
return c.Error(500, "Архив не собран: "+err.Error())
}
logf("migrate: архив %d КБ → %s", buf.Len()/1024, code.URL())
cl := &http.Client{Timeout: moveTimeout, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Post(code.URL(), "application/octet-stream", bytes.NewReader(migrate.SealRequest(code.Key, buf.Bytes())))
if err != nil {
// Не дозвонились — новая точно ничего не получила. Иначе архив мог дойти.
var op *net.OpError
if errors.As(err, &op) && op.Op == "dial" {
end(moveIdle, "")
return c.Error(502, "Новая панель не отвечает: "+err.Error())
}
return m.uncertain(c, end, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != 200 {
end(moveIdle, "")
msg := "Новая панель отказала (" + strconv.Itoa(resp.StatusCode) + ")"
if v, derr := jsjson.Decode(raw); derr == nil {
if e, ok := field(v, "error").(string); ok {
msg = "Новая панель: " + e
}
}
return c.Error(502, msg)
}
var plain []byte
if err == nil {
plain, err = migrate.OpenResponse(code.Key, raw)
}
var join string
if err == nil {
v, derr := jsjson.Decode(plain)
join, _ = field(v, "join").(string)
if derr != nil || !strings.HasPrefix(join, "awgjoin://") {
err = errors.New("ответ без join-строки")
}
}
if err != nil {
return m.uncertain(c, end, err)
}
if err := envfile.Set(m.envFile, [][2]string{{"PANEL_MOVED", "on"}}); err != nil {
return m.uncertain(c, end, err)
}
target := strings.TrimSuffix(code.URL(), "/migrate/v1/import")
m.mu.Lock()
m.target = target
m.mu.Unlock()
end(moveMoved, "")
logf("migrate: новая панель приняла перенос — join, этот сервер станет нодой #0")
m.run("join", join)
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}, {Key: "target", Val: target}})
}
// uncertain — архив ушёл, а подтверждения нет. Подписка остаётся замороженной: если новая
// панель его приняла, вторая живая копия базы разошлась бы с ней.
func (m *Migration) uncertain(c *Ctx, end func(string, string), err error) bool {
msg := "Неизвестно, принят ли перенос (" + err.Error() + "). Откройте новую панель: если там есть сервер #0 — " +
"выполните здесь `awg-ctrl join` со строкой из его карточки; если нет — «Отменить перенос»."
end(moveUncertain, msg)
warnf("migrate: %s", msg)
return c.Error(502, msg)
}
func (m *Migration) cancel(c *Ctx) bool {
m.mu.Lock()
ok := m.state == moveUncertain
if ok {
m.state, m.errText = moveIdle, ""
}
m.mu.Unlock()
if !ok {
return c.Error(409, "Отменять нечего")
}
m.a.sub.frozen.Store(false)
logf("migrate: перенос отменён — подписка снова работает")
return m.status(c)
}
func (m *Migration) exportMeta() jsjson.Object {
name, ip := "VPN", os.Getenv("SERVER_IP")
if r, err := m.a.ctrlLocal("GET", "/health", nil); err == nil {
if s, ok := field(r.Data, "server").(string); ok && s != "" {
name = s
}
if s, ok := field(r.Data, "ip").(string); ok && ip == "" {
ip = s
}
}
return jsjson.Object{
{Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS},
{Key: "node_port", Val: m.a.nodes.port},
}
}
// spawn — `awg-control <args>` отдельным процессом через секунду (ответ успеет уйти). Окружение —
// чистое: CLI читает cli.env заново, а унаследованные значения перекрыли бы новые.
func (m *Migration) spawn(args ...string) {
exe, err := os.Executable()
if err != nil {
warnf("migrate: %v", err)
return
}
var env []string
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
if k == "PATH" || k == "HOME" || k == "LANG" || k == "TZ" || k == "AWG_ROOT" || strings.HasPrefix(k, "LC_") {
env = append(env, kv)
}
}
logFile := filepath.Join(filepath.Dir(m.a.baseDir), "logs", "awg-migrate.log")
go func() {
time.Sleep(time.Second)
cmd := exec.Command(exe, args...)
cmd.Env, cmd.Dir = env, "/"
if f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
cmd.Stdout, cmd.Stderr = f, f
defer f.Close()
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // переживёт эту панель
if err := cmd.Start(); err != nil {
warnf("migrate: awg-control %s: %v", args[0], err)
return
}
cmd.Wait()
}()
}
+174
View File
@@ -0,0 +1,174 @@
package ui
import (
"bufio"
"io"
"net"
"path/filepath"
"strings"
"testing"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testApp(t *testing.T, dir, local string) *App {
t.Helper()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
return a
}
// Перенос панели (`awg-control migrate`): та же ui.db на панели без VPN, старый сервер — нода
// #0. Клиент должен получить тот же конфиг байт в байт, вместе с rev, иначе он пересоздал бы
// туннель, а сервис на macOS — ещё и ключ.
func TestMigratedConfigIsByteIdentical(t *testing.T) {
oldDir, newDir := t.TempDir(), t.TempDir()
old := testApp(t, oldDir, "on")
s := &Sub{a: old, host: "203.0.113.7", port: 40123}
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (1, 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'Нода', 'pub', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0'), (5, 1, 'WG', '10.9.0.9', 'PSK1')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES
(0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0","dns":["1.1.1.1"],"mtu":1376,"gen":"3.1","awg":{"Jc":"4"}}', 1),
(1, '{"name":"Нода","endpoint":"192.0.2.2:47619","server_pub":"S1","gen":"2"}', 1)`,
} {
if _, err := old.db.Exec(q); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
dev := s.deviceByID(old.db, 5)
before := jsjson.MustMarshal(s.buildConfig(dev, nil))
if !strings.Contains(string(before), `"id":0`) || !strings.Contains(string(before), `"id":1`) {
t.Fatalf("в конфиге до переноса нет обоих серверов: %s", before)
}
if _, err := old.db.Exec("VACUUM INTO ?", filepath.Join(newDir, "ui.db")); err != nil {
t.Fatal(err)
}
// Как migrate import: нода #0 ещё не подключалась.
pre := testApp(t, newDir, "off")
if _, err := pre.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, 'VPN', x'00', 9999999999, 1)"); err != nil {
t.Fatal(err)
}
moved := testApp(t, newDir, "off")
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if s2.panelID != s.panelID {
t.Errorf("panel_id: %s → %s — пиры на старом сервере остались бы без хозяина", s.panelID, s2.panelID)
}
after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(moved.db, 5), nil))
if string(before) != string(after) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) || moved.nodes.IsOnline(0) || moved.nodes.DefaultID() != 0 {
t.Errorf("нода #0: exists=%v online=%v default=%d", moved.nodes.Exists(0), moved.nodes.IsOnline(0), moved.nodes.DefaultID())
}
if ids := moved.nodes.IDs(); len(ids) != 2 || ids[0] != 0 || ids[1] != 1 {
t.Errorf("IDs = %v", ids)
}
if r, _ := moved.nodes.CtrlFor(0)("GET", "/health", nil); r.Status != 502 {
t.Errorf("нода #0 не на связи: %d, ожидали 502", r.Status)
}
if l := moved.nodes.List(); len(l) != 2 || l[0].Local || !l[0].Pending {
t.Errorf("List: %+v", l)
}
// Без SUB_HOST адрес подписки — не endpoint ноды #0: это другая машина.
s2.host = ""
if h := s2.subHost(nil); h != "" {
t.Errorf("subHost = %q, ожидали пусто", h)
}
// Свой VPN и нода #0 — один id: такую базу с LOCAL_NODE=on не открываем.
t.Setenv("LOCAL_NODE", "on")
if _, err := newNodes(&App{db: moved.db, baseDir: newDir}); err == nil {
t.Error("LOCAL_NODE=on при ноде #0: ожидали ошибку")
}
}
func TestParseProxyV1(t *testing.T) {
for line, want := range map[string]string{
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "192.0.2.1:51000",
"PROXY TCP6 2001:db8::1 2001:db8::2 51000 40123": "[2001:db8::1]:51000",
"PROXY UNKNOWN": "unknown",
"PROXY UNKNOWN ffff::1 ffff::2 1 2": "unknown",
"PROXY TCP4 2001:db8::1 198.51.100.2 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 70000 1": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 1": "",
"PROXY TCP5 192.0.2.1 198.51.100.2 1 2": "",
"GET / HTTP/1.1": "",
"PROXY TCP4 192.0.2.1 not-an-ip 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "",
} {
addr, ok := parseProxyV1(line)
got := ""
switch {
case ok && addr == nil:
got = "unknown"
case ok:
got = addr.String()
}
if got != want {
t.Errorf("%q → %q, ожидали %q", line, got, want)
}
}
}
// Заголовок PROXY — только от доверенного адреса; соединение с него без заголовка проходит как
// есть, а от остальных заголовок остаётся байтами потока.
func TestProxyListener(t *testing.T) {
try := func(trusted bool, send string) (remote, data string) {
t.Helper()
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer raw.Close()
ln := newProxyListener(raw, map[string]bool{"127.0.0.1": trusted})
cl, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, send)
cl.(*net.TCPConn).CloseWrite()
done := make(chan net.Conn, 1)
go func() { c, _ := ln.Accept(); done <- c }()
select {
case c := <-done:
defer c.Close()
b, _ := io.ReadAll(bufio.NewReader(c))
return c.RemoteAddr().String(), string(b)
case <-time.After(3 * time.Second):
t.Fatal("Accept не вернулся")
}
return "", ""
}
if r, d := try(true, "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"); r != "192.0.2.1:51000" || d != "hello" {
t.Errorf("доверенный с заголовком: %s %q", r, d)
}
if r, d := try(true, "GET / HTTP/1.1\r\n"); !strings.HasPrefix(r, "127.0.0.1:") || d != "GET / HTTP/1.1\r\n" {
t.Errorf("доверенный без заголовка: %s %q", r, d)
}
hdr := "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"
if r, d := try(false, hdr); !strings.HasPrefix(r, "127.0.0.1:") || d != hdr {
t.Errorf("чужой с заголовком: %s %q", r, d)
}
}
+168
View File
@@ -0,0 +1,168 @@
package ui
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
type movePanel struct {
a *App
s *Sub
env string
calls [][]string
}
func newMovePanel(t *testing.T, local, nodePort string) *movePanel {
t.Helper()
dir := t.TempDir()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", nodePort)
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir, port: 34001}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
a.sub = &Sub{a: a, host: "203.0.113.7", port: 40123, locks: map[int]*sync.Mutex{}}
if err := a.sub.initSchema(); err != nil {
t.Fatal(err)
}
p := &movePanel{a: a, s: a.sub, env: filepath.Join(dir, "cli.env")}
a.migration = newMigration(a)
a.migration.envFile = p.env
a.migration.run = func(args ...string) { p.calls = append(p.calls, args) }
return p
}
func (p *movePanel) call(t *testing.T, h handler, body jsjson.Object) (int, jsjson.Object) {
t.Helper()
w := httptest.NewRecorder()
h(&Ctx{W: w, R: httptest.NewRequest("POST", "/", nil), Body: body})
v, _ := jsjson.Decode(w.Body.Bytes())
o, _ := v.(jsjson.Object)
return w.Code, o
}
// Вкладка «Перенос» целиком: код новой панели → архив из старой по HTTP → нода #0, join и
// перезапуск. У клиента конфиг тот же, у старой панели подписка заморожена до join.
func TestMigrateBetweenPanels(t *testing.T) {
old := newMovePanel(t, "on", "")
os.WriteFile(filepath.Join(old.a.baseDir, "sub_sign.key"), []byte("ключ подписи"), 0o600)
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0"}', 1)`,
} {
if _, err := old.a.db.Exec(q); err != nil {
t.Fatal(err)
}
}
before := jsjson.MustMarshal(old.s.buildConfig(old.s.deviceByID(old.a.db, 5), nil))
fresh := newMovePanel(t, "off", "40500")
m := fresh.a.migration
if why := m.canAccept(); why != "" {
t.Fatalf("чистая панель без VPN не принимает: %s", why)
}
if why := old.a.migration.canAccept(); why == "" {
t.Error("панель со своим VPN принимает перенос")
}
if why := fresh.a.migration.canExport(); why == "" {
t.Error("панель без VPN отдаёт перенос")
}
rt := newRouter()
rt.Post("/migrate/v1/import", m.importHandler)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, raw, _, _ := readBody(r, rt.bodyLimit)
rt.routes[0].hs[0](&Ctx{W: w, R: r, Body: body, RawBody: raw})
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port, _ := strconv.Atoi(u.Port())
m.key, m.host, m.expires = migrate.NewKey(), "127.0.0.1", nowFn().Add(moveCodeTTL)
// Чужой ключ: архив не принят, код живёт до пятой неудачи.
wrong := migrate.Code{Host: "127.0.0.1", Port: port, Key: migrate.NewKey()}.String()
if st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: wrong}}); st != 502 || !strings.Contains(o.Get2("error").(string), "Код не подходит") {
t.Fatalf("чужой код: %d %v", st, o)
}
if old.s.frozen.Load() || m.key == nil {
t.Fatal("после отказа подписка заморожена или код сгорел")
}
code := migrate.Code{Host: "127.0.0.1", Port: port, Key: m.key}.String()
st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: code}})
if st != 200 || o.Get2("target") != "http://127.0.0.1:"+u.Port() {
t.Fatalf("export: %d %v", st, o)
}
if !old.s.frozen.Load() {
t.Error("старая подписка не заморожена до join")
}
if len(old.calls) != 1 || old.calls[0][0] != "join" {
t.Fatalf("старая панель: %v", old.calls)
}
j, _ := jsjson.Decode(fromB64url(strings.TrimPrefix(old.calls[0][1], "awgjoin://")))
if field(j, "n") != 0.0 || field(j, "p") != 40500.0 || field(j, "sub") != 40123.0 || field(j, "ui") != 34001.0 {
t.Errorf("join: %s", jsjson.MustMarshal(j))
}
if raw, _ := os.ReadFile(old.env); !strings.Contains(string(raw), "PANEL_MOVED=on") {
t.Errorf("старый cli.env: %q", raw)
}
if len(fresh.calls) != 1 || fresh.calls[0][0] != "restart" || m.state != moveImported || m.key != nil {
t.Errorf("новая панель: %v, state %q", fresh.calls, m.state)
}
raw, _ := os.ReadFile(fresh.env)
for _, want := range []string{"SUB_PORT=40123", "NODE_PORT=40500", "SUB_HOST=127.0.0.1", "RELAY_FROM=127.0.0.1"} {
if !strings.Contains(string(raw), want) {
t.Errorf("новый cli.env без %s: %q", want, raw)
}
}
if k, _ := os.ReadFile(filepath.Join(fresh.a.baseDir, "sub_sign.key")); string(k) != "ключ подписи" {
t.Errorf("ключ подписи: %q", k)
}
// Новая панель после перезапуска: та же база + нода #0 → тот же конфиг.
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(fresh.a.baseDir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
moved := &App{db: db, baseDir: fresh.a.baseDir}
if moved.nodes, err = newNodes(moved); err != nil {
t.Fatal(err)
}
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(db, 5), nil)); string(after) != string(before) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) {
t.Error("нет ноды #0")
}
// Повторный приём: кода уже нет.
if st, _ := old.call(t, func(c *Ctx) bool { old.a.migration.state = moveIdle; return old.a.migration.export(c) },
jsjson.Object{{Key: "code", Val: code}}); st != 502 {
t.Errorf("второй перенос тем же кодом: %d", st)
}
}
+427
View File
@@ -0,0 +1,427 @@
package ui
// Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики.
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove`
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч / 7 дн / 30 дн
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
import (
"errors"
"io"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
type promRange struct{ seconds, step int64 }
// Шаг — чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
// Дальше 30 дней не заглядываем: столько Prometheus хранит (promRetention в internal/cli/monitor.go).
var promRanges = map[string]promRange{
"1h": {3600, 15}, "6h": {21600, 60}, "24h": {86400, 300},
"7d": {604800, 1800}, "30d": {2592000, 7200},
}
// Окно rate() — не короче шага: иначе на неделе/месяце точка была бы случайной двухминутной выборкой
// из двух часов, а не средним за них. Значения «сейчас» при этом считаются отдельно, по окну 2m.
const promRateWindow = "2m"
func withWindow(q, w string) string { return strings.ReplaceAll(q, "["+promRateWindow+"]", "["+w+"]") }
// rated — ряды, чьё «сейчас» показывает вкладка; при расширенном окне их берём мгновенным запросом.
var rated = []string{"down", "up", "cpu"}
// Запросы по серверам. Трафик — со счётчиков интерфейса (монотонны), а не с сумм по пирам.
var promQueries = []struct{ key, q string }{
{"down", `sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8`},
{"up", `sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8`},
{"online", `sum by (server_id, server) (awg_peers_online)`},
{"heavy", `sum by (server_id, server) (awg_shaping_heavy_devices)`},
{"drops", `sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))`},
{"congested", `max by (server_id, server) (awg_shaping_congested)`},
{"scrape_ok", `max by (server_id, server) (awg_scrape_ok)`},
{"cpu", `100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))`},
{"mem", `100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))`},
{"cores", `max by (server_id, server) (awg_host_cpu_cores)`},
{"mem_total", `max by (server_id, server) (awg_host_memory_total_bytes)`},
}
type point [2]float64
type seriesOne struct {
name any
points []point
}
// series — server_id → ряд, в порядке ответа Prometheus (как Map в JS).
type series struct {
ids []int
m map[int]*seriesOne
}
func (s *series) get(id int) *seriesOne {
if s == nil {
return nil
}
return s.m[id]
}
type Monitor struct {
a *App
promURL string
localEnabled bool
http *http.Client
}
func newMonitor(a *App) *Monitor {
return &Monitor{
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
localEnabled: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off",
http: &http.Client{Transport: &http.Transport{Proxy: nil}},
}
}
var errProm = errors.New("Prometheus не отвечает")
func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, error) {
q := url.Values{}
q.Set("query", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("step", strconv.FormatInt(step, 10))
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode())
if err != nil {
return nil, errProm
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errProm
}
out := &series{m: map[int]*seriesOne{}}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
metric := field(r, "metric")
idf := jsToNumberAny(field(metric, "server_id"))
if field(metric, "server_id") == nil {
idf = math.NaN()
}
if idf != float64(int(idf)) {
continue
}
id := int(idf)
name := field(metric, "server")
if name == nil {
name = strconv.Itoa(id)
}
s := &seriesOne{name: name}
for _, v := range arr(field(r, "values")) {
pair := arr(v)
if len(pair) < 2 {
continue
}
t := jsToNumberAny(pair[0])
val := jsToNumberAny(pair[1])
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
if math.IsNaN(val) || math.IsInf(val, 0) {
continue
}
s.points = append(s.points, point{t, val})
}
if _, dup := out.m[id]; !dup {
out.ids = append(out.ids, id)
}
out.m[id] = s
}
return out, nil
}
// instantQuery — значение на момент time по каждому server_id.
func (m *Monitor) instantQuery(query string, time_ int64) (map[int]float64, error) {
q := url.Values{}
q.Set("query", query)
q.Set("time", strconv.FormatInt(time_, 10))
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
resp, err := c.Get(m.promURL + "/api/v1/query?" + q.Encode())
if err != nil {
return nil, errProm
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errProm
}
out := map[int]float64{}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
metric := field(r, "metric")
if field(metric, "server_id") == nil {
continue
}
idf := jsToNumberAny(field(metric, "server_id"))
pair := arr(field(r, "value"))
if idf != float64(int(idf)) || len(pair) < 2 {
continue
}
if v := jsToNumberAny(pair[1]); !math.IsNaN(v) && !math.IsInf(v, 0) {
out[int(idf)] = v
}
}
return out, nil
}
// total — сумма (или максимум) рядов по меткам времени.
func total(s *series, max bool) []any {
acc := map[float64]float64{}
var order []float64
for _, id := range s.ids {
for _, p := range s.m[id].points {
cur, seen := acc[p[0]]
if !seen {
order = append(order, p[0])
}
if max {
acc[p[0]] = math.Max(cur, p[1])
} else {
acc[p[0]] = cur + p[1]
}
}
}
sort.Float64s(order)
out := []any{}
for _, t := range order {
out = append(out, []any{t, acc[t]})
}
return out
}
func last(s *seriesOne) any {
if s == nil || len(s.points) == 0 {
return nil
}
return s.points[len(s.points)-1][1]
}
func (m *Monitor) promReady() bool {
c := &http.Client{Transport: m.http.Transport, Timeout: 1500 * time.Millisecond}
resp, err := c.Get(m.promURL + "/-/ready")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
type monitorJob struct {
can bool
action any
running bool
exit any
log string
}
// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут.
func (m *Monitor) jobState() monitorJob {
if !m.localEnabled {
return monitorJob{}
}
r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil)
if err != nil || r.Status != 200 {
return monitorJob{}
}
j := monitorJob{can: true, action: field(r.Data, "action"), running: truthy(field(r.Data, "running")), exit: field(r.Data, "exit")}
switch l := field(r.Data, "log").(type) {
case nil:
case string:
j.log = l
default:
j.log, _ = jsString(l)
}
return j
}
func (m *Monitor) router() *Router {
rt := newRouter()
rt.Get("/", func(c *Ctx) bool {
var ready bool
var job monitorJob
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); ready = m.promReady() }()
go func() { defer wg.Done(); job = m.jobState() }()
wg.Wait()
var jobOut any
if job.can && truthy(job.action) {
jobOut = jsjson.Object{{Key: "action", Val: job.action}, {Key: "running", Val: job.running}, {Key: "exit", Val: job.exit}, {Key: "log", Val: job.log}}
}
return c.JSON(200, jsjson.Object{{Key: "ready", Val: ready}, {Key: "can_install", Val: job.can}, {Key: "job", Val: jobOut},
{Key: "command", Val: "awg-ctrl monitor install"}})
})
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
ask := func(action string) handler {
return func(c *Ctx) bool {
if !m.localEnabled {
return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине")
}
r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil)
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
if r.Status == 404 {
return c.Error(501, "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную")
}
return c.JSON(r.Status, r.Data)
}
}
rt.Post("/install", ask("install"))
rt.Post("/remove", ask("remove"))
rt.Get("/overview", func(c *Ctx) bool {
qs := c.R.URL.Query()
rangeKey := "1h"
if _, ok := qs["range"]; ok {
rangeKey = strings.Join(qs["range"], ",")
}
rg, ok := promRanges[rangeKey]
if !ok {
return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d")
}
end := now()
start := end - rg.seconds
window := promRateWindow
if rg.step > 120 {
window = strconv.FormatInt(rg.step, 10) + "s"
}
got := make([]*series, len(promQueries))
errs := make([]error, len(promQueries)+2+len(rated))
// Объём за весь период: increase() по тем же монотонным счётчикам (сбросы счётчика он учитывает).
// Окно расширено — «сейчас» с конца ряда было бы средним за шаг; берём мгновенные значения по 2m.
var usedDown, usedUp map[int]float64
nowVals := map[string]map[int]float64{}
var mu sync.Mutex
var wg sync.WaitGroup
for i, pq := range promQueries {
wg.Add(1)
go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(withWindow(pq.q, window), start, end, rg.step) }()
}
span := strconv.FormatInt(rg.seconds, 10) + "s"
n := len(promQueries)
wg.Add(2)
go func() {
defer wg.Done()
usedDown, errs[n] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
}()
go func() {
defer wg.Done()
usedUp, errs[n+1] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
}()
if window != promRateWindow {
for j, k := range rated {
for _, pq := range promQueries {
if pq.key != k {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
v, err := m.instantQuery(pq.q, end)
errs[n+2+j] = err
mu.Lock()
nowVals[k] = v
mu.Unlock()
}()
}
}
}
wg.Wait()
for _, err := range errs {
if err != nil {
return c.Error(502, err.Error())
}
}
used := func(v map[int]float64, id int) any {
if x, ok := v[id]; ok {
return x
}
return nil
}
// Вкладка показывает один сервер — выбранный в сервер-баре (?server=<id>).
if _, has := qs["server"]; has {
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) {
only := int(f)
for i, s := range got {
f := &series{m: map[int]*seriesOne{}}
if x, ok := s.m[only]; ok {
f.ids, f.m[only] = []int{only}, x
}
got[i] = f
}
}
}
s := map[string]*series{}
for i, pq := range promQueries {
s[pq.key] = got[i]
}
cur := func(k string, id int) any {
if v, ok := nowVals[k]; ok {
if x, ok := v[id]; ok {
return x
}
return nil
}
return last(s[k].get(id))
}
ids := map[int]bool{}
for _, x := range got {
for _, id := range x.ids {
ids[id] = true
}
}
var sorted []int
for id := range ids {
sorted = append(sorted, id)
}
sort.Ints(sorted)
servers := []any{}
for _, id := range sorted {
var name any = strconv.Itoa(id)
for _, k := range []string{"scrape_ok", "down", "online"} {
if x := s[k].get(id); x != nil {
name = x.name
break
}
}
servers = append(servers, jsjson.Object{
{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "ok", Val: last(s["scrape_ok"].get(id)) == 1.0},
{Key: "down", Val: cur("down", id)}, {Key: "up", Val: cur("up", id)},
{Key: "online", Val: last(s["online"].get(id))}, {Key: "heavy", Val: last(s["heavy"].get(id))},
{Key: "congested", Val: last(s["congested"].get(id)) == 1.0}, {Key: "cpu", Val: cur("cpu", id)},
{Key: "mem", Val: last(s["mem"].get(id))}, {Key: "cores", Val: last(s["cores"].get(id))},
{Key: "mem_total", Val: last(s["mem_total"].get(id))},
{Key: "used_down", Val: used(usedDown, id)}, {Key: "used_up", Val: used(usedUp, id)},
})
}
var rangeOut any = "1h"
if _, ok := qs["range"]; ok {
rangeOut = rangeKey
}
return c.JSON(200, jsjson.Object{
{Key: "range", Val: rangeOut}, {Key: "step", Val: rg.step}, {Key: "from", Val: start}, {Key: "to", Val: end},
{Key: "servers", Val: servers},
{Key: "total", Val: jsjson.Object{
{Key: "down", Val: total(s["down"], false)}, {Key: "up", Val: total(s["up"], false)}, {Key: "online", Val: total(s["online"], false)},
{Key: "heavy", Val: total(s["heavy"], false)}, {Key: "drops", Val: total(s["drops"], false)},
{Key: "cpu", Val: total(s["cpu"], true)}, {Key: "mem", Val: total(s["mem"], true)},
}},
})
})
return rt
}
+897
View File
@@ -0,0 +1,897 @@
package ui
// Несколько серверов под одной панелью (порт awg-ui/nodes.ts): core (эта панель) и ноды —
// VPN-серверы, которые сами открывают к core исходящее WSS-соединение (awg-control agent).
// Реестр нод лежит в ui.db, RPC к ноде идёт по этому соединению, а нода исполняет запрос на
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
//
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает
// как чистая панель. Номер 0 тогда может занять нода — сервер, с которого панель переехала
// (`awg-control migrate`): так у его пиров, ключей API и устройств не меняется id сервера.
// Протокол и формат join-строки — docs/node-protocol.md.
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"database/sql"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
joinTTL = 3600 // секунд жизни одноразового секрета
authTimeout = 10 * time.Second
healthEvery = 15 * time.Second
pingEvery = 20 * time.Second
rpcTimeout = 15 * time.Second
rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго
hubMaxFrame = 4 * 1024 * 1024
failWindow = 15 * time.Minute
failMaxHits = 20
)
var (
reNodeName = regexp.MustCompile(`^[^\r\n<>]+$`) // длину — lenOK: в JS {1,40} считает UTF-16
reHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`)
reVersion = regexp.MustCompile(`^[\w.+-]{1,32}$`)
)
// authMsg — сообщение, которое нода подписывает своим ключом. Дублируется в internal/agent.
func authMsg(nonce string, nodeID int64) []byte {
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.FormatInt(nodeID, 10))
}
type nodeRow struct {
ID int64
Name string
Pub sql.NullString
JoinHash []byte
JoinExpires sql.NullInt64
CreatedAt int64
LastSeen sql.NullInt64
Version sql.NullString
}
const nodeCols = "id, name, pub, join_hash, join_expires, created_at, last_seen, version"
func scanNode(row interface{ Scan(...any) error }) (*nodeRow, error) {
n := &nodeRow{}
if err := row.Scan(&n.ID, &n.Name, &n.Pub, &n.JoinHash, &n.JoinExpires, &n.CreatedAt, &n.LastSeen, &n.Version); err != nil {
return nil, err
}
return n, nil
}
// health — сводка /health сервера; ip — как пришло (`d.ip ?? ""`).
type health struct {
Server string
IP any
Gen any
Peers any
AwgUp bool
Module any
Tools any
}
func (h *health) json() any {
if h == nil {
return nil
}
return jsjson.Object{{Key: "server", Val: h.Server}, {Key: "ip", Val: h.IP}, {Key: "gen", Val: h.Gen}, {Key: "peers", Val: h.Peers},
{Key: "awg_up", Val: h.AwgUp}, {Key: "module", Val: h.Module}, {Key: "tools", Val: h.Tools}}
}
type pending struct {
ch chan *CtrlResp
timer *time.Timer
}
type nodeConn struct {
ws *websocket.Conn
mu sync.Mutex // seq, pending, alive
seq int64
alive bool
pending map[int64]*pending
closed bool
}
type deleteVeto struct {
Error string
Forcible bool
}
type Nodes struct {
a *App
port int
local bool
tls *tls.Config
pin []byte
mu sync.Mutex
conns map[int64]*nodeConn
health map[int64]*health
failHits map[string]*rateEntry
hmu sync.Mutex
onlineHandler []func(id int)
beforeDelete []func(id int, force bool) *deleteVeto
afterDelete []func(id int)
}
var offline = &CtrlResp{502, errObj("Сервер недоступен")}
func newNodes(a *App) (*Nodes, error) {
n := &Nodes{
a: a, port: envNum("NODE_PORT"),
local: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off",
conns: map[int64]*nodeConn{}, health: map[int64]*health{}, failHits: map[string]*rateEntry{},
}
file := func(env, name string) string {
if v := os.Getenv(env); v != "" {
return v
}
return filepath.Join(a.baseDir, name)
}
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же,
// сколько core: пересоздать = переподключить все ноды.
if n.port != 0 {
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
if err := func() error {
if !exists(keyF) && !exists(crtF) {
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node")
if err := cmd.Run(); err != nil {
return err
}
os.Chmod(keyF, 0o600)
os.Chmod(crtF, 0o600)
logf("nodes: создан самоподписанный сертификат %s", crtF)
}
crt, err := os.ReadFile(crtF)
if err != nil {
return err
}
cert, err := tls.LoadX509KeyPair(crtF, keyF)
if err != nil {
return err
}
pin, err := spkiPin(crt)
if err != nil {
return err
}
n.tls = &tls.Config{Certificates: []tls.Certificate{cert}}
n.pin = pin
return nil
}(); err != nil {
warnf("nodes: сертификат хаба недоступен (%s) — приём нод отключён", crtF)
n.tls, n.pin = nil, nil
}
}
if _, err := a.db.Exec(`
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
pub TEXT,
join_hash BLOB,
join_expires INTEGER,
created_at INTEGER NOT NULL,
last_seen INTEGER
);
`); err != nil {
return nil, err
}
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают).
if !hasColumn(a.db, "nodes", "version") {
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN version TEXT"); err != nil {
return nil, err
}
}
// core_ack — адрес панели, который агент записал к себе (кадр core_ok): нода переживёт
// выключение сервера, через который подключена сейчас.
if !hasColumn(a.db, "nodes", "core_ack") {
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN core_ack TEXT"); err != nil {
return nil, err
}
}
// Нода №0 и локальный сервер — один и тот же id: вместе их не бывает.
if n.local && n.row(0) != nil {
return nil, fmt.Errorf("nodes: в ui.db есть нода #0 (сервер, с которого переехала панель), а LOCAL_NODE=on — " +
"у этой панели свой VPN не включают")
}
return n, nil
}
func exists(f string) bool {
_, err := os.Stat(f)
return err == nil
}
func hasColumn(db queryer, table, col string) bool {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return false
}
defer rows.Close()
cols, _ := rows.Columns()
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
for rows.Next() {
rows.Scan(ptrs...)
for i, c := range cols {
if c == "name" && fmt.Sprint(vals[i]) == col {
return true
}
}
}
return false
}
func (n *Nodes) row(id int64) *nodeRow {
r, _ := scanNode(n.a.db.QueryRow("SELECT "+nodeCols+" FROM nodes WHERE id = ?", id))
return r
}
func (n *Nodes) all() []*nodeRow {
rows, err := n.a.db.Query("SELECT " + nodeCols + " FROM nodes ORDER BY id")
if err != nil {
return nil
}
defer rows.Close()
var out []*nodeRow
for rows.Next() {
if r, err := scanNode(rows); err == nil {
out = append(out, r)
}
}
return out
}
// ── RPC к нодам ─────────────────────────────────────────────────────────────
func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp {
n.mu.Lock()
c := n.conns[id]
n.mu.Unlock()
if c == nil {
return offline
}
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return offline
}
c.seq++
rid := c.seq
p := &pending{ch: make(chan *CtrlResp, 1)}
timeout := rpcTimeout
if strings.HasPrefix(path, "/awg/") {
timeout = rpcSlow
}
p.timer = time.AfterFunc(timeout, func() {
c.mu.Lock()
delete(c.pending, rid)
c.mu.Unlock()
p.ch <- &CtrlResp{504, errObj("Сервер не ответил")}
})
c.pending[rid] = p
c.mu.Unlock()
msg := jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: rid}, {Key: "method", Val: method}, {Key: "path", Val: path}}
if body != nil {
msg = append(msg, jsjson.KV{Key: "body", Val: body})
}
if b, err := jsjson.Marshal(msg); err == nil {
go c.ws.Write(context.Background(), websocket.MessageText, b)
}
return <-p.ch
}
// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS.
func (n *Nodes) CtrlFor(id int) Ctrl {
if id == 0 && n.local {
return n.a.ctrlLocal
}
if n.row(int64(id)) == nil {
if id == 0 {
return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
}
}
return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Сервер не найден")}, nil
}
}
return func(m, p string, b any) (*CtrlResp, error) { return n.rpc(int64(id), m, p, b), nil }
}
func (n *Nodes) Exists(id int) bool {
if id == 0 && n.local {
return true
}
return n.row(int64(id)) != nil
}
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
func (n *Nodes) IsOnline(id int) bool {
if id == 0 && n.local {
return true
}
n.mu.Lock()
defer n.mu.Unlock()
return n.conns[int64(id)] != nil
}
// IDs — все серверы реестра: локальный (если есть) и ноды.
func (n *Nodes) IDs() []int {
var ids []int
if n.local {
ids = append(ids, 0)
}
for _, r := range n.all() {
ids = append(ids, int(r.ID))
}
return ids
}
// OnOnline — подписчики на «сервер снова на связи» (подписка догоняет пропущенное).
func (n *Nodes) OnOnline(cb func(id int)) {
n.hmu.Lock()
n.onlineHandler = append(n.onlineHandler, cb)
n.hmu.Unlock()
}
// BeforeDelete — проверка перед удалением сервера: может запретить (forcible — запрет
// снимается подтверждением оператора). AfterDelete — уборка ссылок на уже удалённый сервер.
func (n *Nodes) BeforeDelete(cb func(id int, force bool) *deleteVeto) {
n.hmu.Lock()
n.beforeDelete = append(n.beforeDelete, cb)
n.hmu.Unlock()
}
func (n *Nodes) AfterDelete(cb func(id int)) {
n.hmu.Lock()
n.afterDelete = append(n.afterDelete, cb)
n.hmu.Unlock()
}
func (n *Nodes) fireOnline(id int) {
n.hmu.Lock()
hs := append([]func(int){}, n.onlineHandler...)
n.hmu.Unlock()
for _, cb := range hs {
func() {
defer func() { recover() }() // подписчик сам разберётся
cb(id)
}()
}
}
// DefaultID — сервер по умолчанию: локальный, а без него — первая нода.
func (n *Nodes) DefaultID() int {
if n.local {
return 0
}
if all := n.all(); len(all) > 0 {
return int(all[0].ID)
}
return 0
}
func (n *Nodes) refreshHealth(id int64) {
var r *CtrlResp
if id == 0 && n.local {
r, _ = n.a.ctrlLocal("GET", "/health", nil)
} else {
r = n.rpc(id, "GET", "/health", nil)
}
var d any
if r != nil {
d = r.Data
}
n.mu.Lock()
defer n.mu.Unlock()
if server, ok := field(d, "server").(string); ok {
or := func(v any, def any) any {
if v == nil {
return def
}
return v
}
n.health[id] = &health{
Server: server, IP: or(field(d, "ip"), ""), Gen: or(field(d, "gen"), "2"), Peers: or(field(d, "awg", "peers"), 0.0),
AwgUp: field(d, "awg", "status") == "ok", Module: or(field(d, "awg", "module"), ""), Tools: or(field(d, "awg", "tools"), ""),
}
} else {
delete(n.health, id)
}
}
// ── Хаб: приём соединений от нод ────────────────────────────────────────────
func (n *Nodes) failed(ip string) {
n.mu.Lock()
defer n.mu.Unlock()
t := nowFn()
e := n.failHits[ip]
if e == nil || e.resetAt.Before(t) {
n.failHits[ip] = &rateEntry{1, t.Add(failWindow)}
} else {
e.count++
}
}
func (n *Nodes) blocked(ip string) bool {
n.mu.Lock()
defer n.mu.Unlock()
e := n.failHits[ip]
return e != nil && e.resetAt.After(nowFn()) && e.count >= failMaxHits
}
// dropConn — все ждущие запросы получают «недоступен»; из реестра соединений — только если
// там всё ещё это соединение.
func (n *Nodes) dropConn(id int64, c *nodeConn) {
c.mu.Lock()
c.closed = true
ps := c.pending
c.pending = map[int64]*pending{}
c.mu.Unlock()
for _, p := range ps {
if p.timer.Stop() {
p.ch <- offline
}
}
n.mu.Lock()
if n.conns[id] == c {
delete(n.conns, id)
delete(n.health, id)
}
n.mu.Unlock()
}
func closeWS(ws *websocket.Conn, code int, reason string) {
go ws.Close(websocket.StatusCode(code), reason)
}
func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled})
if err != nil {
return
}
ws.SetReadLimit(hubMaxFrame)
if n.blocked(ip) {
ws.Close(4029, "rate_limited")
return
}
nb := make([]byte, 16)
rand.Read(nb)
nonce := b64url.EncodeToString(nb)
var conn *nodeConn
var nodeID int64
authTimer := time.AfterFunc(authTimeout, func() { closeWS(ws, 4001, "auth_timeout") })
defer authTimer.Stop()
deny := func() { n.failed(ip); closeWS(ws, 4003, "unauthorized") }
authenticate := func(m jsjson.Object) {
sig, sigOK := m.Get2("sig").(string)
if m.Get2("t") != "auth" || !isInt(m.Get2("node_id")) || !sigOK {
deny()
return
}
row := n.row(int64(m.Get2("node_id").(float64)))
if row == nil {
deny()
return
}
pubB64 := row.Pub.String
joining := !row.Pub.Valid
if joining {
secret, sOK := field(m, "join", "secret").(string)
pub, pOK := field(m, "join", "pub").(string)
if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || !sOK || !pOK {
deny()
return
}
h := sha256.Sum256(fromB64url(secret))
if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 {
deny()
return
}
pubB64 = pub
}
key := fromB64url(pubB64)
if len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, authMsg(nonce, row.ID), fromB64url(sig)) {
deny()
return
}
authTimer.Stop()
if joining {
n.a.db.Exec("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?", pubB64, row.ID)
}
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
var version any
if v, ok := m.Get2("version").(string); ok && reVersion.MatchString(v) {
version = v
}
n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID)
conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}}
nodeID = row.ID
n.mu.Lock()
old := n.conns[row.ID]
n.conns[row.ID] = conn
n.mu.Unlock()
if old != nil {
n.dropConn(row.ID, old)
closeWS(old.ws, 4000, "replaced")
}
ready := jsjson.Object{{Key: "t", Val: "ready"}}
if home := n.home(); home != "" {
ready = append(ready, jsjson.KV{Key: "core", Val: home})
}
ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(ready))
first := ""
if joining {
first = " (первое подключение)"
}
logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first)
go n.refreshHealth(row.ID)
go n.fireOnline(int(row.ID))
}
hello, _ := jsjson.Marshal(jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: nonce}})
ws.Write(r.Context(), websocket.MessageText, hello)
ctx := context.Background()
for {
_, raw, err := ws.Read(ctx)
if err != nil {
break
}
v, err := jsjson.Decode(raw)
if err != nil {
closeWS(ws, 4002, "bad_frame")
continue
}
m, _ := v.(jsjson.Object)
if conn == nil {
authenticate(m)
continue
}
if m.Get2("t") == "core_ok" {
if home, ok := m.Get2("core").(string); ok && home == n.home() {
n.a.db.Exec("UPDATE nodes SET core_ack = ? WHERE id = ?", home, nodeID)
}
continue
}
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
rid := int64(m.Get2("id").(float64))
conn.mu.Lock()
p := conn.pending[rid]
delete(conn.pending, rid)
conn.mu.Unlock()
if p == nil || !p.timer.Stop() {
continue
}
st := jsToNumberAny(m.Get2("status"))
status := 502
if st == st && st != 0 {
status = int(st)
}
p.ch <- &CtrlResp{status, m.Get2("data")}
}
}
if conn != nil {
n.dropConn(nodeID, conn)
logf("nodes: #%d отключена", nodeID)
}
}
// Start поднимает хаб (если настроен) и периодические ping/health.
func (n *Nodes) Start() {
if n.local {
go n.refreshHealth(0)
go n.fireOnline(0)
}
if n.port == 0 || n.tls == nil {
return
}
mux := http.NewServeMux()
mux.HandleFunc("/node/v1", n.serveWS)
srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second,
ErrorLog: quietLog()}
go func() {
ln, err := listen(n.port)
if err != nil {
warnf("nodes: хаб не запущен: %v", err)
return
}
logf("nodes: hub on :%d (wss)", n.port)
srv.ServeTLS(ln, "", "")
}()
go func() {
for range time.Tick(pingEvery) {
n.mu.Lock()
conns := map[int64]*nodeConn{}
for id, c := range n.conns {
conns[id] = c
}
n.mu.Unlock()
for id, c := range conns {
c.mu.Lock()
alive := c.alive
c.alive = false
c.mu.Unlock()
if !alive {
c.ws.CloseNow()
n.dropConn(id, c)
continue
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), pingEvery)
defer cancel()
if c.ws.Ping(ctx) == nil {
c.mu.Lock()
c.alive = true
c.mu.Unlock()
}
}()
}
}
}()
go func() {
for range time.Tick(healthEvery) {
n.mu.Lock()
var ids []int64
for id := range n.conns {
ids = append(ids, id)
}
n.mu.Unlock()
for _, id := range ids {
go n.refreshHealth(id)
}
}
}()
}
// ── Роуты панели (/ui/nodes, за JWT) ────────────────────────────────────────
func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil }
// home — публичный адрес хаба, который он сообщает нодам в ready (агент звонит туда первым).
// Из SUB_HOST: у панели один публичный адрес; не задан — нодам хватает адреса из join.
func (n *Nodes) home() string {
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
if host == "" || !n.hubReady() {
return ""
}
return net.JoinHostPort(host, strconv.Itoa(n.port))
}
func (n *Nodes) joinString(host string, id int64, secret []byte) string {
b := jsjson.MustMarshal(jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: n.port},
{Key: "pin", Val: b64url.EncodeToString(n.pin)}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}})
return "awgjoin://" + b64url.EncodeToString(b)
}
var rePortSuffix = regexp.MustCompile(`:\d+$`)
func (n *Nodes) hostOf(c *Ctx) string {
if given, ok := c.Obj().Get2("host").(string); ok && reHost.MatchString(given) {
return given
}
h := rePortSuffix.ReplaceAllString(c.R.Host, "")
return strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
}
func nullStr(s sql.NullString) any {
if s.Valid {
return s.String
}
return nil
}
func orNil(s string) any {
if s == "" {
return nil
}
return s
}
// List — серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и метрик.
func (n *Nodes) List() []serverInfo {
var out []serverInfo
n.mu.Lock()
hs := map[int64]*health{}
for k, v := range n.health {
hs[k] = v
}
online := map[int64]bool{}
for k := range n.conns {
online[k] = true
}
n.mu.Unlock()
if n.local {
h := hs[0]
name := "VPN"
if h != nil {
name = h.Server
}
out = append(out, serverInfo{ID: 0, Name: name, Local: true, Online: h != nil, Version: orNil(n.a.version), Health: h})
}
for _, r := range n.all() {
out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid,
LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID]})
}
return out
}
type serverInfo struct {
ID int
Name string
Local bool
Online bool
Pending bool
LastSeen any
Version any
Health *health
}
func (s serverInfo) json() jsjson.Object {
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online},
{Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}}
}
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
f := jsToNumber(c.Params["id"])
return int64(f), f == float64(int64(f)) && (f >= 1 || f == 0 && !n.local)
}
func (n *Nodes) router() *Router {
rt := newRouter()
rt.Get("/", func(c *Ctx) bool {
if n.local {
n.refreshHealth(0)
}
list := []any{}
for _, s := range n.List() {
list = append(list, s.json())
}
return c.JSON(200, jsjson.Object{{Key: "hub", Val: n.hubReady()}, {Key: "local", Val: n.local},
{Key: "version", Val: orNil(n.a.version)}, {Key: "nodes", Val: list}})
})
rt.Post("/", func(c *Ctx) bool {
if !n.hubReady() {
return c.Error(503, "Приём нод не настроен (NODE_PORT / сертификат)")
}
name, ok := c.Obj().Get2("name").(string)
if !ok || !reNodeName.MatchString(name) || !lenOK(name, 1, 40) {
return c.Error(400, "Имя: до 40 символов, без переводов строк и <>")
}
host := n.hostOf(c)
if !reHost.MatchString(host) {
return c.Error(400, "Не удалось определить адрес core")
}
secret := make([]byte, 16)
rand.Read(secret)
h := sha256.Sum256(secret)
res, err := n.a.db.Exec("INSERT INTO nodes (name, join_hash, join_expires, created_at) VALUES (?, ?, ?, ?)", name, h[:], now()+joinTTL, now())
if err != nil {
return c.Error(500, "Internal server error")
}
id, _ := res.LastInsertId()
return c.JSON(201, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "join", Val: n.joinString(host, id, secret)}, {Key: "expires_in", Val: joinTTL}})
})
// Новая join-строка для ноды, которая ещё не подключалась (истёк срок / потеряли).
rt.Post("/:id/join", func(c *Ctx) bool {
if !n.hubReady() {
return c.Error(503, "Приём нод не настроен (NODE_PORT / сертификат)")
}
id, ok := n.idOf(c)
var row *nodeRow
if ok {
row = n.row(id)
}
if row == nil {
return c.Error(404, "Не найден")
}
if row.Pub.Valid {
return c.Error(409, "Нода уже подключена")
}
host := n.hostOf(c)
if !reHost.MatchString(host) {
return c.Error(400, "Не удалось определить адрес core")
}
secret := make([]byte, 16)
rand.Read(secret)
h := sha256.Sum256(secret)
n.a.db.Exec("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL", h[:], now()+joinTTL, id)
return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: row.Name}, {Key: "join", Val: n.joinString(host, id, secret)}, {Key: "expires_in", Val: joinTTL}})
})
rt.Patch("/:id", func(c *Ctx) bool {
id, ok := n.idOf(c)
if !ok || n.row(id) == nil {
return c.Error(404, "Не найден")
}
name, ok := c.Obj().Get2("name").(string)
if !ok || !reNodeName.MatchString(name) || !lenOK(name, 1, 40) {
return c.Error(400, "Неверное имя")
}
n.a.db.Exec("UPDATE nodes SET name = ? WHERE id = ?", name, id)
return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name}})
})
rt.Delete("/:id", func(c *Ctx) bool {
id, ok := n.idOf(c)
if !ok || n.row(id) == nil {
return c.Error(404, "Не найден")
}
// Ключи API и мастер-ключи проверяют их владельцы (BeforeDelete).
force := c.R.URL.Query().Get("force") == "1"
n.hmu.Lock()
before := append([]func(int, bool) *deleteVeto{}, n.beforeDelete...)
after := append([]func(int){}, n.afterDelete...)
n.hmu.Unlock()
for _, cb := range before {
veto := func() (v *deleteVeto) {
defer func() {
if recover() != nil {
v = &deleteVeto{"Не удалось подготовить сервер к удалению", true}
}
}()
return cb(int(id), force)
}()
if veto != nil {
return c.JSON(409, jsjson.Object{{Key: "error", Val: veto.Error}, {Key: "can_force", Val: veto.Forcible && !force}})
}
}
if n.row(id) == nil {
return c.Error(404, "Не найден")
}
n.mu.Lock()
conn := n.conns[id]
n.mu.Unlock()
if conn != nil {
n.dropConn(id, conn)
closeWS(conn.ws, 4004, "removed")
}
n.a.db.Exec("DELETE FROM nodes WHERE id = ?", id)
// Хвосты подписки для этого сервера больше некому доделать.
for _, t := range []string{"peer_tombstones", "server_profiles"} {
n.a.db.Exec("DELETE FROM "+t+" WHERE server_id = ?", id)
}
for _, cb := range after {
func() {
defer func() {
if p := recover(); p != nil {
warnf("nodes: уборка после удаления #%d: %v", id, p)
}
}()
cb(int(id))
}()
}
return c.JSON(200, jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: id}})
})
return rt
}
+149
View File
@@ -0,0 +1,149 @@
package ui
// PROXY protocol v1 от ретранслятора (`awg-control relay`): после переноса панели старый
// сервер пересылает на неё подписку, хаб нод и панель. Без заголовка все клиенты пришли бы с
// его IP и делили бы на всех лимиты по адресу (неудачные запросы, регистрации, вход). Заголовку
// верим только от адресов из RELAY_FROM; от остальных он — просто байты соединения.
import (
"bufio"
"net"
"os"
"strconv"
"strings"
"time"
)
const proxyHeaderTimeout = 5 * time.Second
// relayFrom — RELAY_FROM: IP ретрансляторов через запятую.
func relayFrom() map[string]bool {
out := map[string]bool{}
for _, s := range strings.Split(os.Getenv("RELAY_FROM"), ",") {
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
out[ip.String()] = true
}
}
return out
}
// listen — net.Listen("tcp", :port), а при заданном RELAY_FROM — с разбором PROXY v1.
func listen(port int) (net.Listener, error) {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(port))
if err != nil {
return nil, err
}
if trusted := relayFrom(); len(trusted) > 0 {
return newProxyListener(ln, trusted), nil
}
return ln, nil
}
type accepted struct {
c net.Conn
err error
}
// proxyListener читает заголовок в своей горутине на соединение: медленный ретранслятор не
// держит Accept для остальных.
type proxyListener struct {
net.Listener
trusted map[string]bool
ch chan accepted
}
func newProxyListener(ln net.Listener, trusted map[string]bool) *proxyListener {
l := &proxyListener{Listener: ln, trusted: trusted, ch: make(chan accepted)}
go l.loop()
return l
}
func (l *proxyListener) loop() {
for {
c, err := l.Listener.Accept()
if err != nil {
l.ch <- accepted{nil, err}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return
}
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
if ip := net.ParseIP(host); ip == nil || !l.trusted[ip.String()] {
l.ch <- accepted{c, nil}
continue
}
go func() {
if pc := readProxyHeader(c); pc != nil {
l.ch <- accepted{pc, nil}
}
}()
}
}
func (l *proxyListener) Accept() (net.Conn, error) {
a := <-l.ch
return a.c, a.err
}
type proxyConn struct {
net.Conn
r *bufio.Reader
remote net.Addr
}
func (c *proxyConn) Read(b []byte) (int, error) { return c.r.Read(b) }
func (c *proxyConn) RemoteAddr() net.Addr { return c.remote }
// readProxyHeader — соединение без заголовка остаётся как есть (с ретранслятора может прийти и
// обычный клиент); битый заголовок — закрываем. nil — соединение закрыто.
func readProxyHeader(c net.Conn) net.Conn {
c.SetReadDeadline(time.Now().Add(proxyHeaderTimeout))
defer c.SetReadDeadline(time.Time{})
r := bufio.NewReaderSize(c, 256)
pc := &proxyConn{Conn: c, r: r, remote: c.RemoteAddr()}
if sig, err := r.Peek(6); err != nil || string(sig) != "PROXY " {
if err != nil && len(sig) == 0 {
c.Close()
return nil
}
return pc
}
// Строка v1 — не длиннее 107 байт вместе с CRLF.
line, err := r.ReadSlice('\n')
if err != nil || len(line) > 107 || !strings.HasSuffix(string(line), "\r\n") {
c.Close()
return nil
}
if addr, ok := parseProxyV1(strings.TrimSuffix(string(line), "\r\n")); ok {
if addr != nil {
pc.remote = addr
}
return pc
}
c.Close()
return nil
}
// parseProxyV1 — «PROXY TCP4|TCP6 src dst sport dport» или «PROXY UNKNOWN …» (addr == nil).
func parseProxyV1(line string) (*net.TCPAddr, bool) {
f := strings.Split(line, " ")
if len(f) >= 2 && f[0] == "PROXY" && f[1] == "UNKNOWN" {
return nil, true
}
if len(f) != 6 || f[0] != "PROXY" || f[1] != "TCP4" && f[1] != "TCP6" {
return nil, false
}
ip := net.ParseIP(f[2])
if ip == nil || net.ParseIP(f[3]) == nil || (ip.To4() != nil) != (f[1] == "TCP4") {
return nil, false
}
port, err := strconv.Atoi(f[4])
if err != nil || port < 0 || port > 65535 {
return nil, false
}
if p, err := strconv.Atoi(f[5]); err != nil || p < 0 || p > 65535 {
return nil, false
}
return &net.TCPAddr{IP: ip, Port: port}, true
}
+102
View File
@@ -0,0 +1,102 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
// После переноса старый сервер выключают, когда новый адрес знают все: хаб сообщает его
// нодам в ready и запоминает подтверждение (core_ok), клиенты получают его в endpoints.
// Вкладка «Перенос» показывает, сколько осталось.
func TestRetireReadiness(t *testing.T) {
t.Setenv("SUB_HOST", "198.51.100.20")
p := newMovePanel(t, "off", "40500")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
for _, q := range []string{
"INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'Старый', 'x', 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', '" + b64url.EncodeToString(pub) + "', 1)",
"INSERT INTO nodes (id, name, created_at) VALUES (4, 'Ещё не подключена', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (2, 'u2', 'Не открыта', x'02', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (3, 'u3', 'Отозвана', x'03', 1, 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (5, 1, 'a', 'W5', 'A5', 1, 2000)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (6, 1, 'b', 'W6', 'A6', 1, 500)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (7, 1, 'c', 'W7', 'A7', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (8, 3, 'd', 'W8', 'A8', 1, 2000)",
} {
if _, err := p.a.db.Exec(q); err != nil {
t.Fatal(q, err)
}
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
t.Fatal(err)
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o
}
write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) }
nonce := read().Get2("nonce").(string)
write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}})
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
t.Fatalf("ready: %s", got)
}
retire := func() jsjson.Object {
_, o := p.call(t, p.a.migration.status, nil)
r, _ := o.Get2("retire").(jsjson.Object)
return r
}
if retire() != nil {
t.Error("готовность показана панели, которая ничего не принимала")
}
t.Setenv("MOVED_IN_AT", "1000")
if n := string(jsjson.MustMarshal(retire().Get2("nodes"))); n != `[{"id":3,"name":"Frankfurt","online":true,"ok":false}]` {
t.Errorf("до core_ok: %s", n)
}
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "203.0.113.66:1"}}) // не наш адрес — мимо
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "198.51.100.20:40500"}})
var r jsjson.Object
for range 50 {
if r = retire(); strings.Contains(string(jsjson.MustMarshal(r.Get2("nodes"))), `"ok":true`) {
break
}
time.Sleep(20 * time.Millisecond)
}
want := `{"since":1000,"home":"198.51.100.20:40500","old":{"exists":true,"online":false},` +
`"devices":{"total":3,"fresh":1},"unused_links":1,"nodes":[{"id":3,"name":"Frankfurt","online":true,"ok":true}]}`
if got := string(jsjson.MustMarshal(r)); got != want {
t.Errorf("готовность:\n got %s\nwant %s", got, want)
}
os.WriteFile(p.env, []byte("UI_PASS=x\nMOVED_IN_AT=1000\n"), 0o600)
w := httptest.NewRecorder()
p.a.migration.router().ServeHTTP(w, httptest.NewRequest("DELETE", "/retire", nil))
if raw, _ := os.ReadFile(p.env); strings.Contains(string(raw), "MOVED_IN_AT") || retire() != nil {
t.Errorf("отметка не убрана: %q", raw)
}
}
+430
View File
@@ -0,0 +1,430 @@
package ui
// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1 (порт awg-ui/sen.ts).
// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент реализуется по
// ней, поэтому любое изменение здесь — изменение протокола.
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"hash/crc32"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
senPrefix = "sen://"
senVersion = 1
flagTLS = 0x01
addrV4 = 0x04
addrV6 = 0x06
addrDomain = 0x44
)
type senLinkError struct{ msg string }
func (e *senLinkError) Error() string { return e.msg }
func senErr(f string, a ...any) error { return &senLinkError{fmt.Sprintf(f, a...)} }
type SenAddr struct {
Host string `json:"host"`
Port int `json:"port"`
}
type SenLink struct {
TLS bool
Addrs []SenAddr
Secret []byte // 16 байт
SignPub []byte // 32 байта, Ed25519
TLSPin []byte // 32 байта, только при TLS
Name string
}
// b64url — Buffer.toString("base64url"): без паддинга.
var b64url = base64.RawURLEncoding
// fromB64url повторяет Buffer.from(s, "base64url") в Node: терпит паддинг и «+/», хвостовой
// одиночный символ (неполный байт) молча отбрасывает, мусор после первого чужого символа — тоже.
func fromB64url(s string) []byte {
s = strings.NewReplacer("+", "-", "/", "_").Replace(s)
if i := strings.IndexFunc(s, func(r rune) bool {
return !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_')
}); i >= 0 {
s = s[:i]
}
if len(s)%4 == 1 {
s = s[:len(s)-1]
}
b, _ := b64url.DecodeString(s)
return b
}
// fromB64 — Buffer.from(s, "base64") (обычный алфавит, тоже нестрого).
func fromB64(s string) []byte {
return fromB64url(strings.TrimRight(s, "="))
}
// ── Адреса ─────────────────────────────────────────────────────────────────
var (
reHex4 = regexp.MustCompile(`^[0-9a-fA-F]{1,4}$`)
reIPv4 = regexp.MustCompile(`^\d{1,3}(\.\d{1,3}){3}$`)
reDomain = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
)
func ipv6ToBytes(host string) []byte {
parts := strings.Split(host, "::")
if len(parts) > 2 {
return nil
}
var head, tail []string
if parts[0] != "" {
head = strings.Split(parts[0], ":")
}
if len(parts) == 2 && parts[1] != "" {
tail = strings.Split(parts[1], ":")
}
missing := 8 - len(head) - len(tail)
if len(parts) == 1 && len(head) != 8 || len(parts) == 2 && missing < 1 {
return nil
}
groups := append([]string{}, head...)
if len(parts) == 2 {
for range missing {
groups = append(groups, "0")
}
}
groups = append(groups, tail...)
if len(groups) != 8 {
return nil
}
out := make([]byte, 16)
for i, g := range groups {
if !reHex4.MatchString(g) {
return nil
}
n, _ := strconv.ParseUint(g, 16, 16)
binary.BigEndian.PutUint16(out[i*2:], uint16(n))
}
return out
}
func bytesToIPv6(b []byte) string {
g := make([]string, 8)
for i := range 8 {
g[i] = strconv.FormatUint(uint64(binary.BigEndian.Uint16(b[i*2:])), 16)
}
// Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи.
bestStart, bestLen := -1, 0
for i := 0; i < 8; {
if g[i] != "0" {
i++
continue
}
j := i
for j < 8 && g[j] == "0" {
j++
}
if j-i > bestLen {
bestStart, bestLen = i, j-i
}
i = j
}
if bestLen < 2 {
return strings.Join(g, ":")
}
return strings.Join(g[:bestStart], ":") + "::" + strings.Join(g[bestStart+bestLen:], ":")
}
func encodeAddr(a SenAddr) ([]byte, error) {
if a.Port < 1 || a.Port > 65535 {
return nil, senErr("Неверный порт: %d", a.Port)
}
p := binary.BigEndian.AppendUint16(nil, uint16(a.Port))
if reIPv4.MatchString(a.Host) {
out := []byte{addrV4}
for _, o := range strings.Split(a.Host, ".") {
n, _ := strconv.Atoi(o)
if n > 255 {
return nil, senErr("Неверный IPv4: %s", a.Host)
}
out = append(out, byte(n))
}
return append(out, p...), nil
}
if strings.Contains(a.Host, ":") {
b := ipv6ToBytes(a.Host)
if b == nil {
return nil, senErr("Неверный IPv6: %s", a.Host)
}
return append(append([]byte{addrV6}, b...), p...), nil
}
if len(a.Host) < 1 || len(a.Host) > 253 || !reDomain.MatchString(a.Host) {
return nil, senErr("Неверный домен: %s", a.Host)
}
return append(append([]byte{addrDomain, byte(len(a.Host))}, a.Host...), p...), nil
}
// ── Ссылка ─────────────────────────────────────────────────────────────────
func EncodeSenLink(l SenLink) (string, error) {
if len(l.Addrs) < 1 || len(l.Addrs) > 3 {
return "", senErr("Адресов должно быть от 1 до 3")
}
if len(l.Secret) != 16 {
return "", senErr("secret: 16 байт")
}
if len(l.SignPub) != 32 {
return "", senErr("signPub: 32 байта")
}
if l.TLS && len(l.TLSPin) != 32 {
return "", senErr("tlsPin: 32 байта при TLS")
}
if len(l.Name) > 64 {
return "", senErr("Имя: до 64 байт")
}
var flags byte
if l.TLS {
flags = flagTLS
}
body := []byte{senVersion, flags, byte(len(l.Addrs))}
for _, a := range l.Addrs {
b, err := encodeAddr(a)
if err != nil {
return "", err
}
body = append(body, b...)
}
body = append(body, l.Secret...)
body = append(body, l.SignPub...)
if l.TLS {
body = append(body, l.TLSPin...)
}
body = append(body, byte(len(l.Name)))
body = append(body, l.Name...)
body = binary.BigEndian.AppendUint32(body, crc32.ChecksumIEEE(body))
return senPrefix + b64url.EncodeToString(body), nil
}
var (
reB64urlChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
reSpaces = regexp.MustCompile(`\s+`)
)
func DecodeSenLink(link string) (SenLink, error) {
var l SenLink
t := strings.TrimSpace(link)
if !strings.HasPrefix(strings.ToLower(t), senPrefix) {
return l, senErr("Ссылка должна начинаться с sen://")
}
payload := reSpaces.ReplaceAllString(t[len(senPrefix):], "")
if !reB64urlChars.MatchString(payload) {
return l, senErr("Ссылка повреждена: недопустимые символы")
}
raw := fromB64url(payload)
if len(raw) < 3+4 {
return l, senErr("Ссылка слишком короткая")
}
body := raw[:len(raw)-4]
if binary.BigEndian.Uint32(raw[len(raw)-4:]) != crc32.ChecksumIEEE(body) {
return l, senErr("Ссылка повреждена: не сошлась контрольная сумма")
}
off := 0
errCut := senErr("Ссылка обрезана")
take := func(n int) ([]byte, error) {
if off+n > len(body) {
return nil, errCut
}
s := body[off : off+n]
off += n
return s, nil
}
b1 := func() (byte, error) {
b, err := take(1)
if err != nil {
return 0, err
}
return b[0], nil
}
version, err := b1()
if err != nil {
return l, err
}
if version != senVersion {
return l, senErr("Неизвестная версия ссылки: %d", version)
}
flags, err := b1()
if err != nil {
return l, err
}
if flags&^flagTLS != 0 {
return l, senErr("Неизвестные флаги ссылки")
}
n, err := b1()
if err != nil {
return l, err
}
if n < 1 || n > 3 {
return l, senErr("Неверное число адресов")
}
for range n {
typ, err := b1()
if err != nil {
return l, err
}
var host string
switch typ {
case addrV4:
b, err := take(4)
if err != nil {
return l, err
}
host = fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
case addrV6:
b, err := take(16)
if err != nil {
return l, err
}
host = bytesToIPv6(b)
case addrDomain:
ln, err := b1()
if err != nil {
return l, err
}
if ln < 1 {
return l, senErr("Пустой домен")
}
b, err := take(int(ln))
if err != nil {
return l, err
}
host = jsUTF8(b)
if !reDomain.MatchString(host) {
return l, senErr("Неверный домен")
}
default:
return l, senErr("Неизвестный тип адреса: %d", typ)
}
pb, err := take(2)
if err != nil {
return l, err
}
port := int(binary.BigEndian.Uint16(pb))
if port == 0 {
return l, senErr("Порт 0")
}
l.Addrs = append(l.Addrs, SenAddr{host, port})
}
if l.Secret, err = take(16); err != nil {
return l, err
}
if l.SignPub, err = take(32); err != nil {
return l, err
}
l.TLS = flags&flagTLS != 0
if l.TLS {
if l.TLSPin, err = take(32); err != nil {
return l, err
}
}
nameLen, err := b1()
if err != nil {
return l, err
}
if nameLen > 64 {
return l, senErr("Имя длиннее 64 байт")
}
nb, err := take(int(nameLen))
if err != nil {
return l, err
}
l.Name = jsUTF8(nb)
if off != len(body) {
return l, senErr("Лишние данные в конце ссылки")
}
l.Secret, l.SignPub = bytes.Clone(l.Secret), bytes.Clone(l.SignPub)
l.TLSPin = bytes.Clone(l.TLSPin)
return l, nil
}
// jsUTF8 — Buffer.toString("utf8"): битые последовательности заменяются на U+FFFD.
func jsUTF8(b []byte) string {
if utf8.Valid(b) {
return string(b)
}
return strings.ToValidUTF8(string(b), "�")
}
// ── Ключи Ed25519 ──────────────────────────────────────────────────────────
// loadEd25519Private читает PKCS#8 PEM (как crypto.createPrivateKey в Node).
func loadEd25519Private(raw []byte) (ed25519.PrivateKey, error) {
blk, _ := pem.Decode(raw)
if blk == nil {
return nil, errors.New("not a PEM key")
}
k, err := x509.ParsePKCS8PrivateKey(blk.Bytes)
if err != nil {
return nil, err
}
priv, ok := k.(ed25519.PrivateKey)
if !ok {
return nil, errors.New("not an Ed25519 key")
}
return priv, nil
}
// spkiPin — SHA-256 от SPKI DER сертификата (первого в PEM) — то, что кладётся в ссылку как pin.
func spkiPin(certPEM []byte) ([]byte, error) {
blk, _ := pem.Decode(certPEM)
if blk == nil {
return nil, errors.New("not a PEM certificate")
}
c, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return nil, err
}
sum := sha256.Sum256(c.RawSubjectPublicKeyInfo)
return sum[:], nil
}
// ── Подписи протокола ──────────────────────────────────────────────────────
// signedResponse — {"body": "<JSON-строка>", "sig": "<base64url>"}; sig — Ed25519 над
// UTF-8 байтами строки body. body — именно строка, чтобы клиенту не нужна была
// каноническая сериализация JSON.
func signResponse(payload any, key ed25519.PrivateKey) jsjson.Object {
body := string(jsjson.MustMarshal(payload))
return jsjson.Object{{Key: "body", Val: body}, {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, []byte(body)))}}
}
// requestSigString — METHOD \n /path \n ts \n hex(sha256(тело запроса)); path — без query.
func requestSigString(method, path string, ts int64, body []byte) string {
h := sha256.Sum256(body)
return strings.ToUpper(method) + "\n" + path + "\n" + strconv.FormatInt(ts, 10) + "\n" + hex.EncodeToString(h[:])
}
func verifyRequest(method, path string, ts int64, body []byte, sig string, authPub []byte) bool {
if len(authPub) != ed25519.PublicKeySize {
return false
}
return ed25519.Verify(authPub, []byte(requestSigString(method, path, ts, body)), fromB64url(sig))
}
func crc32IEEE(b []byte) uint32 { return crc32.ChecksumIEEE(b) }

Some files were not shown because too many files have changed in this diff Show More