Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45c96aa0b6 | ||
|
|
eb19cacf93 | ||
|
|
0687650d26 | ||
|
|
3511b8c6c3 | ||
|
|
28ccc17bde | ||
|
|
d6fc2049c3 | ||
|
|
ea91265674 | ||
|
|
de8a32ac41 | ||
|
|
a84845cecb | ||
|
|
c73da602d6 | ||
|
|
cf6af5ce0a | ||
|
|
105f184a93 | ||
|
|
2388a42275 | ||
|
|
e70c5468f5 | ||
|
|
bac230f144 | ||
|
|
5aa0c3a2f5 | ||
|
|
70bb587216 | ||
|
|
c2e9636f31 | ||
|
|
5687ba0d3d | ||
|
|
016d3de62d | ||
|
|
59e076c1de | ||
|
|
53d5a08bbd | ||
|
|
7fbff82e76 | ||
|
|
565b2759ed | ||
|
|
f0f2f9848a | ||
|
|
5c3bf21918 |
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -20,4 +20,7 @@ yarn-error.log*
|
||||
|
||||
# Все markdown, кроме README.md
|
||||
*.md
|
||||
!README.md
|
||||
!README.md
|
||||
# Go: бинарники и прогоны дифференциального стенда
|
||||
tools/diffbench/bin/
|
||||
tools/diffbench/out/
|
||||
|
||||
@@ -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 (автозапуск на загрузке):
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-agent",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||
"scripts": {
|
||||
|
||||
+240
-17
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.5",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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 }));
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+12
-1
@@ -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 ?? []));
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 у вкладки, поэтому та
|
||||
// перечитывает данные уже с новым сервером.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"id": "apikeys",
|
||||
"label": "API-ключи",
|
||||
"icon": "key",
|
||||
"group": "main",
|
||||
"order": 2
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "audit",
|
||||
"label": "Аудит",
|
||||
"icon": "shield",
|
||||
"group": "server",
|
||||
"order": 1.8,
|
||||
"chrome": {
|
||||
"serverBar": false,
|
||||
"restart": false
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"id": "masterkeys",
|
||||
"label": "Мастер-ключи",
|
||||
"icon": "key",
|
||||
"group": "main",
|
||||
"order": 1.5
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "migrate",
|
||||
"label": "Перенос",
|
||||
"icon": "move",
|
||||
"group": "server",
|
||||
"order": 1.9,
|
||||
"chrome": {
|
||||
"serverBar": false,
|
||||
"restart": false
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "monitor",
|
||||
"label": "Монитор",
|
||||
"icon": "pulse",
|
||||
"group": "server",
|
||||
"order": 1.7,
|
||||
"chrome": {
|
||||
"serverBar": false,
|
||||
"restart": false
|
||||
}
|
||||
}
|
||||
@@ -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,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">
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"id": "users",
|
||||
"label": "Пользователи",
|
||||
"icon": "users",
|
||||
"group": "main",
|
||||
"order": 1
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+7
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 для продолжения..."))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ¬null, &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("Подсеть заполнена")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 завис: запрос в базу изнутри цикла по строкам")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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("открылся повреждённый")
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user