7 Commits
Author SHA1 Message Date
maeneko eb9fd700cb Bump to Beta 0.4.6
ci / check (push) Successful in 27m15s
release / release (push) Successful in 19m57s
2026-10-21 19:26:55 +03:00
maeneko 96c21408e6 Prometheus on a node; panel under a URL prefix; open ports on migrate import
A panel without VPN (e.g. after the move) can use a Prometheus that lives on
a node, such as the one with history left on the old server (node #0). Both
directions go through the agent's WSS: the node's Prometheus scrapes
127.0.0.1:AGENT_METRICS_PORT/metrics, the agent checks its token and asks
core (metrics → metrics_res, the same exposition as /metrics, at most once
per 5 s per connection); the monitor tab reads it back through the agent,
which forwards only GET /-/ready and /api/v1/query[_range]. The panel finds
it by itself (local first, then the lowest-id node), and the install/remove
buttons work on a panel without VPN. `monitor install` on a node without its
own panel picks AGENT_METRICS_PORT, writes it to cli.env and restarts the
agent; on the old server it keeps the collected data.

The panel works behind nginx in a subfolder: relative asset and API paths,
relative directory redirects, and the client address from X-Forwarded-For /
X-Real-IP only from trusted proxies (loopback, TRUST_PROXY).

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

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

«Серверы для всех» on the master keys tab adds or removes servers on every
live key in one go, with a dry run that warns about API keys that would lose
sight of some keys, and sets the servers new keys get.
2026-10-21 15:59:33 +03:00
maeneko 0687650d26 Bump to Beta 0.4.4
ci / check (push) Successful in 24m29s
release / release (push) Successful in 19m48s
2026-10-20 20:39:09 +03:00
maeneko 3511b8c6c3 Master keys: a down server no longer breaks the device list, 403 for keys outside the API key's scope
masterDevices skips a server that did not answer and reports it as
unreachable; peers are fetched once per server per pass. The tab loads each
key on its own, counts devices from the key when the list is missing (and still
asks before unbinding), drops stale responses, labels an IP from another
server and blocks a double create. masterFor answers 403 instead of 404 for a
key that exists but is outside the API key's servers, so a client does not
mistake it for a deleted one. updateMaster checks every statement in its
transaction.
2026-10-20 20:39:09 +03:00
49 changed files with 4815 additions and 225 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.4.3
VERSION=0.4.6
+48 -4
View File
@@ -33,6 +33,14 @@
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
```
Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с
`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор
с текстом скрипта.
```bash
sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)"
```
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
на 2.0 можно так:
@@ -49,6 +57,34 @@ AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgettin
`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика
(подробности — в комментариях `install.sh` и в `.env`).
### Панель за nginx
Панель работает и в корне домена, и в любой подпапке — пути в ней относительные,
настраивать сам префикс не нужно. Пример для `https://example.com/forgetting/`
(`39708` — `PORT` панели из `/opt/awg-control/cli/cli.env`):
```nginx
location = /forgetting { return 301 /forgetting/; }
location ^~ /forgetting/ {
proxy_pass http://127.0.0.1:39708/; # слэш в конце срезает префикс
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Адрес клиента панель берёт из `X-Forwarded-For` / `X-Real-IP` только от доверенного
прокси: с этой же машины (loopback) — всегда, с других адресов — если они перечислены
в `TRUST_PROXY` в `cli.env` (IP или сети через запятую, например
`TRUST_PROXY=10.0.0.5,192.168.1.0/24`), после чего `awg-ctrl restart`. От остальных
заголовки игнорируются. Без этого все входы шли бы с адреса прокси и делили один
лимит неудачных попыток.
Сам порт панели после этого можно закрыть снаружи (`ufw deny <PORT>/tcp`) — порты
подписки (`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты
и ноды ходят на них напрямую, не через nginx.
## API
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
@@ -151,7 +187,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.3" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.6" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
@@ -198,8 +234,9 @@ base64url-декодировать, отбросить первые 4 байта
| Метод | Что делает |
|---|---|
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`; `:id` в путях — id или `uuid` |
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` |
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }], defaults }`; `:id` в путях — id или `uuid`; `defaults` — серверы новых ключей или `null` |
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201`; без `servers` — `defaults`, а нет их — сервер по умолчанию |
| `POST /ui/masterkeys/servers` | «Серверы для всех»: `{ add?, remove?, defaults?, dry_run? }` — `add` появляются у всех живых ключей, `remove` снимаются у всех, прочие серверы ключей не трогаются; `defaults` (`null` — сбросить) — набор новых ключей. → `{ changed, total, hidden: [{ id, label, keys }] }`, `hidden` — ключи API, которые перестанут видеть столько мастер-ключей (сервер вне их области). `409`, если у ключа не останется серверов |
| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
@@ -211,7 +248,14 @@ base64url-декодировать, отбросить первые 4 байта
## Управление
```bash
awg-ctrl
awg-ctrl # меню
awg-ctrl start|stop|restart|status
awg-ctrl credentials [user|pass]
awg-ctrl join '<awgjoin://…>' [--no-panel] / unjoin
awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес
awg-ctrl migrate export [файл] # перенос панели на отдельный сервер без VPN
awg-ctrl migrate import <архив> # (порядок — docs/node-protocol.md, «Перенос панели»)
awg-ctrl migrate cancel
```
Через systemd (автозапуск на загрузке):
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-agent",
"version": "0.4.3",
"version": "0.4.6",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.4.3",
"version": "0.4.6",
"description": "",
"main": "dist/index.js",
"scripts": {
+73 -5
View File
@@ -53,7 +53,9 @@ interface Dev { id: number; device_id: string; device_name: string; platform: st
interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
let mkId = 1, devId = 1;
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] },
{ id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0] },
{ id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }];
const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
@@ -116,11 +118,56 @@ app.get("/ui/monitor", (_q, r) => {
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 });
// MOCK_ROLE=core/retire — панель без VPN: Prometheus на ноде #0 (сервер, с которого она переехала)
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", host: MOCK_CORE ? { id: 0, name: "VPN" } : null, 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"];
@@ -248,11 +295,25 @@ app.post("/ui/masterkeys/audit/clean", (_q, r) => {
mockGhost = false; r.json(mockAudit());
});
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
let mkDefaults: number[] | null = null;
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut), defaults: mkDefaults }));
// «Серверы для всех» (sub_admin.go bulkServers): add — у всех, remove — ни у кого.
app.post("/ui/masterkeys/servers", (q, r) => {
const add: number[] = q.body.add ?? [], remove: number[] = q.body.remove ?? [];
if (add.some(x => remove.includes(x))) return r.status(400).json({ error: "Сервер не может быть и у всех, и ни у кого" });
const next = masters.map(m => ({ m, s: [...new Set([...m.servers.filter(x => !remove.includes(x)), ...add])].sort() }));
const empty = next.filter(x => !x.s.length).length;
if (empty) return r.status(409).json({ error: `У ${empty} ключей не останется ни одного сервера — оставьте им хотя бы один` });
const changed = next.filter(x => x.s.join() !== [...x.m.servers].sort().join());
const within = (l: number[], sc: number[]) => l.every(x => sc.includes(x));
const hidden = apiKeys.map(k => ({ id: k.id, label: k.label, keys: changed.filter(x => within(x.m.servers, k.servers) && !within(x.s, k.servers)).length })).filter(h => h.keys);
if (!q.body.dry_run) { changed.forEach(x => { x.m.servers = x.s; }); if ("defaults" in q.body) mkDefaults = q.body.defaults; }
r.json({ changed: changed.length, total: masters.length, hidden });
});
app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3;
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? mkDefaults ?? [0] };
masters.push(m); r.status(201).json(mkOut(m));
});
app.patch("/ui/masterkeys/:id", (q, r) => {
@@ -276,7 +337,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 }));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.4.3",
"version": "0.4.6",
"private": true,
"scripts": {
"dev": "vite",
+7
View File
@@ -102,9 +102,16 @@ export const IcoPulse = () => (
</svg>
);
export const IcoMove = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="8" height="16" rx="2"/><rect x="14" y="4" width="8" height="16" rx="2"/><path d="M7 12h10"/><path d="m14 9 3 3-3 3"/>
</svg>
);
export const ICONS: Record<string, () => JSX.Element> = {
users: IcoUsers,
key: IcoKey,
shield: IcoShield,
pulse: IcoPulse,
move: IcoMove,
};
+9
View File
@@ -3,8 +3,17 @@
// LICENSE file in the root directory of this source tree.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import axios from 'axios';
import App from './App';
// Пути к API в коде пишутся от корня («/ui/nodes»), а уходят от адреса страницы («ui/nodes»):
// так панель за reverse-proxy в подпапке (example.com/forgetting/) ходит в свой префикс, а в корне
// всё как раньше. Одно место на все запросы, включая будущие.
axios.interceptors.request.use(c => {
if (c.url?.startsWith('/') && !c.url.startsWith('//')) c.url = c.url.slice(1);
return c;
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
@@ -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>
);
}
+87 -32
View File
@@ -1,13 +1,14 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, 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. Мастер-ключ — это ссылка
@@ -41,9 +42,13 @@ const statusChip = (d: Device) => (
</span>
);
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом.
const ipOf = (d: Device): string =>
(d.ips?.find(x => x.server_id === getServerId()) ?? d.ips?.[0])?.ip ?? '—';
// 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[]>([]);
@@ -56,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]);
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
@@ -77,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]);
@@ -119,15 +152,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [fetchLink, showMsg]);
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
// поэтому сперва спросить и назвать, кого именно.
// поэтому сперва спросить и назвать, кого именно. Список устройств не загрузился — считаем по
// счётчику ключа и имён не называем, но спросить всё равно надо.
const saveLimit = useCallback(async (k: MasterKey) => {
const limit = Number(limitEdit);
const list = devs[k.id] ?? [];
const extra = list.length - limit;
const list = devs[k.id];
const have = list?.length ?? k.devices;
const extra = have - limit;
if (Number.isInteger(limit) && extra > 0) {
const names = [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
.map(d => `«${d.device_name || d.id}»`).join(', ');
if (!confirm(`Лимит ${limit} меньше, чем устройств (${list.length}). Будут отвязаны подключившиеся последними: ${names}. Продолжить?`)) return;
const who = list
? [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
.map(d => `«${d.device_name || d.id}»`).join(', ')
: `${extra} шт.`;
if (!confirm(`Лимит ${limit} меньше, чем устройств (${have}). Будут отвязаны подключившиеся последними: ${who}. Продолжить?`)) return;
}
try {
const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit });
@@ -281,6 +318,13 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</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></>;
@@ -304,12 +348,14 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
);
};
// Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного.
const ipCell = (d: Device) => {
const all = d.ips ?? [];
const ip = <code className="mk-ip">{ipOf(d)}</code>;
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(' · ')}>{ip}</span>
: ip;
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
: text;
};
const caret = (k: MasterKey) => (
@@ -333,7 +379,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
<div className="mk-device-ip">
<IcoGlobe />
<span className="mk-ip">{ipOf(d)}</span>
{ipCell(d)}
<PriorityChip priority={d.priority} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
</div>
@@ -361,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>
@@ -385,14 +433,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</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 />{nameCell(k)}</span></td>
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
{summary(list)}
<td>{meter(k, list.length)}</td>
<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>}
@@ -449,15 +498,16 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</li>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const isFolded = folded.has(k.id);
return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
{caret(k)}
<IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, list.length)}
<span className="mk-master-label">{k.label}{dataFlag(k)}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, count)}
{actions(k)}
</div>
{tools === k.id && toolbox(k)}
@@ -475,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()}>
+30
View File
@@ -279,3 +279,33 @@ LICENSE file in the root directory of this source tree.
.mk-free { padding: 12px; font-size: 13px; }
}
/* «Серверы для всех» (BulkServersModal) */
.bs-modal { max-width: 620px; }
.bs-list { display: flex; flex-direction: column; gap: 8px; }
.bs-row {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container); border-radius: 12px; padding: 8px 8px 8px 14px;
font-size: 13px; color: var(--on-surface);
}
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--neutral-text); }
.bs-sub .server-pick-ip { margin-left: 0; }
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
.bs-opt {
min-height: 36px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill);
cursor: pointer; white-space: nowrap;
}
.bs-opt:hover { background: var(--state-primary); }
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.bs-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--on-surface); cursor: pointer; }
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
@media (max-width: 640px) {
.bs-row { flex-wrap: wrap; }
.bs-opts { width: 100%; }
.bs-opt { flex: 1; }
}
.bs-modal .btn:disabled { opacity: .5; cursor: default; }
+229
View File
@@ -0,0 +1,229 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, type PageProps } from '../../lib/shared';
import { IcoCopy } from '../../components/icons';
import './migrate.css';
// Вкладка «Перенос»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
type MoveState = '' | 'sending' | 'moved' | 'uncertain' | 'imported';
// Новая панель после приёма: кто уже знает её адрес — тогда старый сервер можно выключать.
interface Retire {
since: number;
home: string | null; // адрес хаба для нод; null — не задан SUB_HOST
old: { exists: boolean; online: boolean };
devices: { total: number; fresh: number }; // fresh — получили конфиг после переноса
unused_links: number; // мастер-ключи без устройств: в ссылке старый адрес
nodes: { id: number; name: string; online: boolean; ok: boolean }[];
}
interface MigrateInfo {
state: MoveState;
error: string | null;
target: string | null; // старая: адрес новой панели
code: { code: string; expires_in: number } | null;
export?: string | null; // null — можно, строка — почему нельзя
accept?: string | null;
retire?: Retire | null;
}
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function MigratePage({ token, showMsg }: PageProps) {
const [info, setInfo] = useState<MigrateInfo | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
try { setInfo(await apiFetch('GET', '/ui/migrate', token)); }
catch { /* панель перезапускается — следующий опрос покажет */ }
}, [token]);
useEffect(() => { if (token) load(); }, [token, load]);
// Пока висит код — ждём, когда старая панель пришлёт архив; после приёма панель
// перезапускается, и страница перечитывается уже с перенесённой базой.
const waiting = !!info?.code || info?.state === 'imported' || !!info?.retire;
useEffect(() => {
if (!waiting) return;
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [waiting, load]);
useEffect(() => {
if (info?.state !== 'imported' || info.error) return;
const t = setTimeout(() => window.location.reload(), 8000);
return () => clearTimeout(t);
}, [info?.state, info?.error]);
const call = async (method: string, path: string, body?: object) => {
setBusy(true); setError('');
try { setInfo(await apiFetch(method, `/ui/migrate${path}`, token, body)); }
catch (e) { setError(errText(e)); }
finally { setBusy(false); }
};
const send = async () => {
if (!window.confirm('Перенести панель? Здесь она остановится, а этот сервер станет нодой новой панели.')) return;
setBusy(true); setError('');
try {
await apiFetch('POST', '/ui/migrate/export', token, { code: code.trim() });
await load();
} catch (e) { setError(errText(e)); await load(); }
finally { setBusy(false); }
};
if (!info) return null;
if (info.state === 'moved') return (
<section className="mv-box">
<span className="chip chip--online mv-self"><span className="chip-dot" />Панель перенесена</span>
<p className="mv-text">
Новая панель: <a href={info.target ?? '#'}>{info.target}</a>. Через несколько секунд этот сервер станет её
нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать,
а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.
</p>
{info.target && <a className="btn btn--primary mv-self" href={info.target}>Открыть новую панель</a>}
</section>
);
if (info.state === 'imported') return (
<section className="mv-box">
<span className={`chip chip--${info.error ? 'error' : 'online'} mv-self`}><span className="chip-dot" />
{info.error ? 'Приём прерван' : 'Панель принята'}</span>
<p className="mv-text">
{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}
</p>
</section>
);
const r = info.retire;
const retireBox = r && (() => {
const devicesOk = r.devices.fresh >= r.devices.total;
const nodesOk = r.nodes.every(n => n.ok);
const ready = devicesOk && nodesOk && !!r.home;
const chip = (ok: boolean, text: string, offline = false) => (
<span className={`chip chip--${ok ? 'online' : offline ? 'offline' : 'warm'}`}><span className="chip-dot" />{text}</span>
);
return (
<section className="mv-box" key="retire">
<h4 className="mv-title">Отключение старого сервера</h4>
<p className="mv-text">
{r.old.exists
? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его
можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними.</>
: <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.</>}
</p>
<div className="mv-checks">
<div className="mv-check">
{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)}
<span>устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}</span>
</div>
{r.nodes.map(n => (
<div className="mv-check" key={n.id}>
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)}
<span>нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}</span>
</div>
))}
{r.unused_links > 0 && (
<div className="mv-check">
{chip(false, String(r.unused_links), true)}
<span>мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на
вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же</span>
</div>
)}
</div>
{!r.home && <p className="mv-notice">Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.</p>}
<ol className="mv-steps">
<li>Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.</li>
<li>Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.</li>
<li>Удалите сервер «#0» и выключите старую машину.</li>
</ol>
{!r.old.exists && (
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('DELETE', '/retire')}>Готово, скрыть</button>
)}
</section>
);
})();
const canExport = info.export === null, canAccept = info.accept === null;
const exportBox = (
<section className="mv-box" key="export">
<h4 className="mv-title">Перенести эту панель на другой сервер</h4>
{canExport ? (
<>
<p className="mv-text">
Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели.
Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.
</p>
<ol className="mv-steps">
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
<li>Там на вкладке «Перенос» нажмите «Получить код».</li>
<li>Вставьте код сюда и нажмите «Перенести».</li>
</ol>
{info.state === 'uncertain' ? (
<>
<p className="mv-notice">{info.error}</p>
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('POST', '/cancel')}>
Отменить перенос
</button>
</>
) : (
<div className="mv-row">
<input className="field" placeholder="awgmove://…" value={code} disabled={busy}
onChange={e => setCode(e.target.value)} />
<button className="btn btn--primary" disabled={busy || !code.trim().startsWith('awgmove://')} onClick={send}>
{busy ? 'Переносим…' : 'Перенести'}
</button>
</div>
)}
</>
) : <p className="mv-text mv-muted">{info.export}</p>}
</section>
);
const acceptBox = (
<section className="mv-box" key="accept">
<h4 className="mv-title">Принять панель с другого сервера</h4>
{canAccept ? (
info.code ? (
<>
<p className="mv-text">
Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
</p>
<code className="node-modal-cmd">{info.code.code}</code>
<div className="mv-row mv-row--end">
<button className="btn btn--tonal" onClick={() => copyText(info.code!.code).then(() => showMsg('Код скопирован'))}>
<IcoCopy /> Скопировать
</button>
<button className="btn btn--outline" disabled={busy} onClick={() => call('DELETE', '/code')}>Отозвать</button>
</div>
<p className="mv-text mv-muted">Ждём архив от старой панели…</p>
</>
) : (
<>
<p className="mv-text">
Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней
нодой #0. Логин и пароль останутся этой панели.
</p>
<button className="btn btn--primary mv-self" disabled={busy}
onClick={() => call('POST', '/code', { host: window.location.hostname })}>
Получить код
</button>
</>
)
) : <p className="mv-text mv-muted">{info.accept}</p>}
</section>
);
return (
<>
{/* Доступное действие — первым. */}
{retireBox}
{canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]}
{error && <p className="mv-notice">{error}</p>}
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "migrate",
"label": "Перенос",
"icon": "move",
"group": "server",
"order": 1.9,
"chrome": {
"serverBar": false,
"restart": false
}
}
+26
View File
@@ -0,0 +1,26 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.mv-title { margin: 0; font-size: 14px; font-weight: 500; }
.mv-text { margin: 0; font-size: 14px; line-height: 1.5; }
.mv-muted { font-size: 13px; color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg);
border-radius: 16px; padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 640px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-box .btn:disabled { opacity: .5; cursor: default; }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: 14px; line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
+14 -10
View File
@@ -8,11 +8,12 @@ import LineChart from '../../components/LineChart';
import './monitor.css';
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
// Кнопка «Подключить Prometheus» просит локальный awg-ctrl поставить его (`awg-ctrl monitor install`, root;
// сама панель пакетов не ставит). Графики читают Prometheus через фиксированные запросы панели
// (awg-ui/monitor.ts).
// Кнопка «Подключить Prometheus» просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root;
// сама панель пакетов не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента.
// Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go).
interface Status {
ready: boolean; can_install: boolean; command: string;
host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
}
type Pts = [number, number][];
@@ -93,7 +94,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
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; }
try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token]);
@@ -139,7 +140,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
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(); }
try { await apiFetch('POST', `/ui/monitor/${action}?server=${getServerId() ?? 0}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
const working = !!pending || !!status?.job?.running;
@@ -174,7 +175,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span>
</div>
<p className="mon-hint">
Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики —
Панель поставит Prometheus {status.host ? <>на сервер <b>{status.host.name}</b></> : 'на этот сервер'} и сама подключит его к своим метрикам: основные графики —
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
только 127.0.0.1, наружу он не открывается.
</p>
@@ -188,7 +189,10 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):</p>
<p className="mon-hint">
У этой панели нет своего VPN-сервера: выберите ноду в сервер-баре, чтобы поставить Prometheus на неё
кнопкой, или выполните на ней от root:
</p>
<code className="mon-code">{status.command}</code>
<div className="mon-actions">
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
@@ -210,8 +214,8 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<span className="chip chip--online"><span className="chip-dot" />работает</span>
</div>
<p className="mon-hint">
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет
пакет и сотрёт накопленные данные.
Стоит {status.host ? <>на сервере <b>{status.host.name}</b> (панель читает его через агента ноды)</> : 'на этом сервере'} и
слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет пакет и сотрёт накопленные данные.
</p>
{status.can_install ? (
<div className="mon-actions">
@@ -222,7 +226,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере от root:</p>
<p className="mon-hint">Выполните на сервере с Prometheus от root:</p>
<code className="mon-code">awg-ctrl monitor remove</code>
</>
)}
+4
View File
@@ -5,6 +5,10 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
// Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в
// подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же
// (main.tsx).
base: './',
plugins: [react()],
server: {
proxy: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.4.3",
"version": "0.4.6",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+9 -1
View File
@@ -1,4 +1,4 @@
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent и команды оператора.
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent / relay и команды оператора.
package main
import (
@@ -11,6 +11,7 @@ import (
"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"
)
@@ -60,6 +61,13 @@ func main() {
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:])
}
+4 -3
View File
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места.
VERSION="0.4.3"
VERSION="0.4.6"
CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
@@ -702,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 в строке.
+265 -12
View File
@@ -1,7 +1,9 @@
// Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее
// WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет
// присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет,
// awg-ctrl остаётся закрытым от сети. Протокол и формат join-строки — docs/node-protocol.md.
// awg-ctrl остаётся закрытым от сети. Если на ноде стоит Prometheus (`awg-ctrl monitor install`),
// агент отдаёт ему метрики панели, взятые у core, и пропускает к нему запросы вкладки «Монитор».
// Протокол и формат join-строки — docs/node-protocol.md.
package agent
import (
@@ -10,13 +12,16 @@ import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -29,6 +34,7 @@ import (
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
@@ -42,11 +48,14 @@ const (
ctrlTimeout = 110 * time.Second
maxBackoff = 60 * time.Second
closeAuthFailed = 4003
promTimeout = 8 * time.Second // как у панели к своему Prometheus
metricsTimeout = 9 * time.Second // меньше scrape_timeout Prometheus (10 с по умолчанию)
)
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
@@ -55,6 +64,9 @@ type Config struct {
CLIEnvFile string
JoinSecret string // одноразовый, живёт до первого успешного подключения
Version string
PromURL string // Prometheus этой машины: к нему агент пропускает запросы панели (/prom/…)
MetricsPort int // 127.0.0.1:порт, где Prometheus ноды скрейпит метрики панели; 0 — мониторинга на ноде нет
MonitorHash string // хэш токена этого Prometheus (его пишет `awg-ctrl monitor install`)
Logf, Errorf func(format string, a ...any)
}
@@ -77,6 +89,8 @@ func envFromFile(file, key string) string {
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
@@ -87,18 +101,22 @@ func ConfigFromEnv() (Config, error) {
CoreHost: os.Getenv("CORE_HOST"),
CorePort: atoi("CORE_PORT"),
CorePin: os.Getenv("CORE_PIN"),
NodeID: atoi("NODE_ID"),
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"),
PromURL: or("PROM_URL", "http://127.0.0.1:9090"),
MetricsPort: atoi("AGENT_METRICS_PORT"),
MonitorHash: or("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"),
}
if c.Version == "" {
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION")
}
if c.CoreHost == "" || c.CorePort == 0 || c.CorePin == "" || c.NodeID == 0 {
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
@@ -112,8 +130,21 @@ type Agent struct {
mu sync.Mutex
joinSecret string
addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT)
cur int
link *coreLink // соединение после ready; nil — core сейчас недоступен
http *http.Client
prom *http.Client
}
// coreLink — запросы ноды к core (пока один: метрики панели для Prometheus ноды).
type coreLink struct {
send func(v any)
done <-chan struct{}
mu sync.Mutex
seq int64
pending map[int64]chan reply
}
// loadNodeKey — личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
@@ -151,7 +182,9 @@ func New(c Config) (*Agent, error) {
nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)),
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout},
prom: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: promTimeout},
}
a.setAddrs(c.CoreEndpoint)
if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil {
a.internalKey = ik
} else {
@@ -204,11 +237,13 @@ func errReply(status int, msg string) reply {
return reply{status, jsjson.Object{{Key: "error", Val: msg}}}
}
// execute выполняет запрос core на локальном awg-ctrl. Ответ возвращается как его разобрал
// бы axios: JSON — значением, иначе строкой (пустое тело — "").
// execute выполняет запрос core на локальном awg-ctrl (или, для /prom/…, на Prometheus).
func (a *Agent) execute(m jsjson.Object) reply {
method, _ := m.Get2("method").(string)
method = strings.ToUpper(method)
if s, _ := m.Get2("path").(string); strings.HasPrefix(s, "/prom/") {
return a.execProm(method, s)
}
p, ok := pathAllowed(m.Get2("path"))
if !methods[method] || !ok {
return errReply(400, "Запрос не разрешён агентом")
@@ -234,14 +269,19 @@ func (a *Agent) execute(m jsjson.Object) reply {
if a.internalKey != nil {
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now()))
}
resp, err := a.http.Do(req)
return do(a.http, req, "awg-ctrl недоступен")
}
// do — ответ как его разобрал бы axios: JSON — значением, иначе строкой (пустое тело — "").
func do(c *http.Client, req *http.Request, down string) reply {
resp, err := c.Do(req)
if err != nil {
return errReply(502, "awg-ctrl недоступен")
return errReply(502, down)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errReply(502, "awg-ctrl недоступен")
return errReply(502, down)
}
if len(raw) == 0 {
return reply{resp.StatusCode, ""}
@@ -252,6 +292,40 @@ func (a *Agent) execute(m jsjson.Object) reply {
return reply{resp.StatusCode, string(raw)}
}
// promPaths — всё, что панель читает у Prometheus: готовность и фиксированные запросы вкладки.
var promPaths = map[string]bool{"/prom/-/ready": true, "/prom/api/v1/query": true, "/prom/api/v1/query_range": true}
// promAllowed — запрос панели к Prometheus этой машины: только GET и только пути из promPaths.
func promAllowed(method string, p any) (string, bool) {
s, ok := p.(string)
if method != "GET" || !ok || len(s) > 8192 || !rePath.MatchString(s) {
return "", false
}
pathname, query, _ := strings.Cut(s, "?")
if !promPaths[pathname] || strings.Contains(query, "#") {
return "", false
}
return strings.TrimPrefix(s, "/prom"), true
}
// execProm — Prometheus ноды для вкладки «Монитор» панели. Пропускаем, только если он
// скрейпит панель через этого агента (AGENT_METRICS_PORT): чужой Prometheus на машине
// панели не отдаём, да и метрик панели в нём нет.
func (a *Agent) execProm(method string, p any) reply {
if a.c.MetricsPort == 0 {
return errReply(404, "Prometheus на этой ноде не подключён к панели")
}
rest, ok := promAllowed(method, p)
if !ok {
return errReply(400, "Запрос не разрешён агентом")
}
req, err := http.NewRequest("GET", strings.TrimSuffix(a.c.PromURL, "/")+rest, nil)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
return do(a.prom, req, "Prometheus не отвечает")
}
// ── Соединение с core ──────────────────────────────────────────────────────
func spkiPin(rawCert []byte) (string, error) {
@@ -306,6 +380,71 @@ func (a *Agent) forgetJoinSecret() {
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()
@@ -337,7 +476,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout)
ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", hostPort(a.c.CoreHost, a.c.CorePort)), &websocket.DialOptions{
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 },
@@ -352,6 +491,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
return false
}
defer ws.CloseNow()
defer a.setLink(nil)
ws.SetReadLimit(maxPayload)
send := func(v any) {
@@ -398,6 +538,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
case t == "ready":
ready = true
armDead()
a.setLink(&coreLink{send: send, done: ctx.Done(), pending: map[int64]chan reply{}})
if onReady != nil {
onReady()
}
@@ -405,7 +546,14 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
a.forgetJoinSecret()
a.c.Logf("agent: нода привязана к core")
}
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 == "metrics_res" && ready:
a.deliver(m)
case t == "req" && ready:
id, ok := m.Get2("id").(float64)
if !ok || id != float64(int64(id)) {
@@ -420,6 +568,104 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
}
}
// ── Метрики панели для Prometheus ноды ─────────────────────────────────────
func (a *Agent) setLink(l *coreLink) {
a.mu.Lock()
a.link = l
a.mu.Unlock()
}
// deliver — ответ core на запрос метрик.
func (a *Agent) deliver(m jsjson.Object) {
id, ok := m.Get2("id").(float64)
a.mu.Lock()
l := a.link
a.mu.Unlock()
if !ok || l == nil {
return
}
l.mu.Lock()
ch := l.pending[int64(id)]
delete(l.pending, int64(id))
l.mu.Unlock()
if ch == nil {
return
}
st, _ := m.Get2("status").(float64)
text, _ := m.Get2("text").(string)
ch <- reply{int(st), text}
}
// coreMetrics — экспозиция /metrics панели, как её отдал бы сам core.
func (a *Agent) coreMetrics(ctx context.Context) reply {
a.mu.Lock()
l := a.link
a.mu.Unlock()
if l == nil {
return reply{503, "core недоступен\n"}
}
ch := make(chan reply, 1)
l.mu.Lock()
l.seq++
id := l.seq
l.pending[id] = ch
l.mu.Unlock()
defer func() {
l.mu.Lock()
delete(l.pending, id)
l.mu.Unlock()
}()
l.send(jsjson.Object{{Key: "t", Val: "metrics"}, {Key: "id", Val: id}})
select {
case r := <-ch:
return r
case <-l.done:
return reply{503, "core недоступен\n"}
case <-ctx.Done():
return reply{504, "core не ответил\n"}
}
}
// tokenOK — тот же токен, что встроенный Prometheus предъявил бы самой панели: хэш в файле,
// перечитываем на каждый скрейп (раз в 15 с), чтобы переустановка не требовала перезапуска.
func (a *Agent) tokenOK(r *http.Request) bool {
given, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
raw, err := os.ReadFile(a.c.MonitorHash)
if !ok || !strings.HasPrefix(given, "awgm_") || err != nil {
return false
}
want, err := hex.DecodeString(strings.TrimSpace(string(raw)))
sum := sha256.Sum256([]byte(given))
return err == nil && len(want) == len(sum) && subtle.ConstantTimeCompare(sum[:], want) == 1
}
// serveMetrics — 127.0.0.1:AGENT_METRICS_PORT/metrics для Prometheus этой ноды: метрики всех
// серверов панели, полученные у core по уже открытому WSS. Наружу порт не открывается.
func (a *Agent) serveMetrics(ctx context.Context) {
mux := http.NewServeMux()
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
if !a.tokenOK(r) {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", 401)
return
}
cctx, cancel := context.WithTimeout(r.Context(), metricsTimeout)
defer cancel()
res := a.coreMetrics(cctx)
text, _ := res.data.(string)
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(res.status)
io.WriteString(w, text)
})
srv := &http.Server{Addr: "127.0.0.1:" + strconv.Itoa(a.c.MetricsPort), Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() { <-ctx.Done(); srv.Close() }()
a.c.Logf("agent: метрики панели для Prometheus — http://%s/metrics", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
a.c.Errorf("agent: метрики для Prometheus не подняты: %v", err)
}
}
func hostPort(host string, port int) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
host = "[" + host + "]"
@@ -429,10 +675,17 @@ func hostPort(host string, port int) string {
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
func (a *Agent) Run(ctx context.Context) error {
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, hostPort(a.c.CoreHost, a.c.CorePort))
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr())
if a.c.MetricsPort > 0 {
go a.serveMetrics(ctx)
}
backoff := time.Second
for {
authFailed := a.connectOnce(ctx, func() { backoff = time.Second })
ready := false
authFailed := a.connectOnce(ctx, func() { backoff, ready = time.Second, true })
if !ready {
a.next()
}
wait := backoff
if authFailed {
wait = maxBackoff
+201
View File
@@ -4,9 +4,12 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
@@ -268,3 +271,201 @@ func TestNodeKeyPersists(t *testing.T) {
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")
}
}
func TestPromAllowed(t *testing.T) {
for p, want := range map[string]string{
"/prom/-/ready": "/-/ready",
"/prom/api/v1/query?query=up&time=1": "/api/v1/query?query=up&time=1",
"/prom/api/v1/query_range?query=up": "/api/v1/query_range?query=up",
"/prom/api/v1/admin/tsdb/delete_series": "",
"/prom/api/v1/query/../admin": "",
"/prom//api/v1/query": "",
"/prom/api/v1/query?query=up#x": "",
"/prom/api/v1/query?query=up\nHost: x": "",
"/prom/api/v1/labels": "",
} {
got, ok := promAllowed("GET", p)
if want == "" && ok || want != "" && got != want {
t.Errorf("promAllowed(%q) = %q, %v", p, got, ok)
}
}
if _, ok := promAllowed("POST", "/prom/api/v1/query"); ok {
t.Error("POST к Prometheus пропущен")
}
a := &Agent{c: Config{PromURL: "http://127.0.0.1:1"}}
if r := a.execProm("GET", "/prom/-/ready"); r.status != 404 {
t.Errorf("без AGENT_METRICS_PORT: %d", r.status)
}
}
// Prometheus на ноде: запросы панели доходят до него, а его скрейп получает метрики core.
func TestPrometheusOnNode(t *testing.T) {
prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/query":
io.WriteString(w, `{"status":"success","q":"`+r.URL.Query().Get("query")+`"}`)
case "/-/ready":
io.WriteString(w, "Prometheus Server is Ready.\n")
default:
w.WriteHeader(500)
}
}))
defer prom.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: "n"}})
if read() == nil {
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: "GET"}, {Key: "path", Val: "/prom/api/v1/query?query=up"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/admin/tsdb/snapshot"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/-/ready"}})
for {
m := read()
if m == nil {
return
}
switch m.Get2("t") {
case "res":
results <- m
case "metrics":
send(ctx, ws, jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: m.Get2("id")}, {Key: "status", Val: 200}, {Key: "text", Val: "awg_up 1\n"}})
}
}
})
l, _ := net.Listen("tcp", "127.0.0.1:0")
port := l.Addr().(*net.TCPAddr).Port
l.Close()
a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
a.c.PromURL, a.c.MetricsPort = prom.URL, port
a.c.MonitorHash = filepath.Join(t.TempDir(), "monitor_token.sha256")
sum := sha256.Sum256([]byte("awgm_good"))
os.WriteFile(a.c.MonitorHash, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go a.Run(ctx)
byID := map[float64]string{}
for range 3 {
select {
case r := <-results:
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
case <-ctx.Done():
t.Fatal("нет ответов агента")
}
}
for id, want := range map[float64]string{
1: `{"t":"res","id":1,"status":200,"data":{"status":"success","q":"up"}}`,
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
3: `{"t":"res","id":3,"status":200,"data":"Prometheus Server is Ready.\n"}`,
} {
if byID[id] != want {
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
}
}
scrape := func(tok string) (int, string) {
req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(port)+"/metrics", nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
if st, _ := scrape("awgm_bad"); st != 401 {
t.Errorf("чужой токен: %d", st)
}
if st, body := scrape("awgm_good"); st != 200 || body != "awg_up 1\n" {
t.Errorf("скрейп: %d %q", st, body)
}
}
+66 -19
View File
@@ -17,6 +17,7 @@ import (
"syscall"
"time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson"
)
@@ -181,6 +182,14 @@ func New() *CLI {
{"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", "")},
// Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP.
{"TRUST_PROXY", env("TRUST_PROXY", "")},
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
{"CLI_ENV_FILE", cliEnv},
// Часовой аудит пиров на всех серверах — только у core.
{"AUDIT", audit},
{"METRICS_PEERS", env("METRICS_PEERS", "off")},
@@ -196,6 +205,7 @@ func New() *CLI {
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")},
@@ -203,9 +213,17 @@ func New() *CLI {
{"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")},
// Prometheus на ноде (`monitor install`): здесь он скрейпит метрики панели.
{"AGENT_METRICS_PORT", env("AGENT_METRICS_PORT", "")},
},
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 с
@@ -224,9 +242,41 @@ func New() *CLI {
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
}
// setSvcEnv — значение, записанное в cli.env уже после запуска CLI, для следующего start.
func (c *CLI) setSvcEnv(name, key, value string) {
s := c.svc[name]
for i := range s.env {
if s.env[i][0] == key {
s.env[i][1] = value
return
}
}
s.env = append(s.env, [2]string{key, value})
}
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" {
@@ -285,6 +335,9 @@ func (c *CLI) pidMatches(name string, pid int) bool {
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))
}
@@ -476,10 +529,14 @@ func (c *CLI) startAll() {
}
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" {
if n != "awgctrl" && n != "relay" {
rest = append(rest, n)
labels = append(labels, c.svc[n].label)
}
@@ -515,23 +572,9 @@ func contains(s []string, v string) bool {
// ── cli.env ─────────────────────────────────────────────────────────────────
func updateEnvVar(content, key, value string) string {
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
if loc := re.FindStringIndex(content); loc != nil {
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
}
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
}
func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) }
func removeEnvVar(content, key string) string {
var kept []string
for _, l := range strings.Split(content, "\n") {
if !strings.HasPrefix(l, key+"=") {
kept = append(kept, l)
}
}
return strings.Join(kept, "\n")
}
func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
func (c *CLI) readEnvFile() string {
raw, _ := os.ReadFile(c.envFile)
@@ -568,10 +611,10 @@ func (c *CLI) confirmYes(q string) bool {
// ── Точка входа ─────────────────────────────────────────────────────────────
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>"
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", "peers", "monitor"}
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"}
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
func Main(args []string) {
@@ -608,6 +651,10 @@ func Main(args []string) {
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":
+42 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
@@ -61,6 +62,10 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
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(" Ссылка неверного формата или от другой версии панели")
@@ -71,7 +76,8 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
}
hasPanel := os.Getenv("UI_PASS") != ""
keepPanel := !hasFlag && hasPanel
moved := c.panelMoved()
keepPanel := !hasFlag && hasPanel && !moved
content := c.readEnvFile()
np := "off"
if keepPanel {
@@ -81,11 +87,18 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
{"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 && c.askDropPanelPeers()
// После переноса этими пирами управляет новая панель: снимать нечего.
dropPeers := !keepPanel && hasPanel && !moved && c.askDropPanelPeers()
if !keepPanel {
c.stop("ui")
}
@@ -99,9 +112,35 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
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")
@@ -109,7 +148,7 @@ func (c *CLI) unjoinCore() {
}
c.stop("agent")
content := c.readEnvFile()
for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"} {
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)
+300
View File
@@ -0,0 +1,300 @@
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)
}
// Подписка и хаб переезжают на порты старого сервера — их надо открыть, как install.sh.
opened, failed := migrate.OpenFirewall(plan.SubPort, plan.NodePort)
if len(opened) > 0 {
fmt.Printf(" %s UFW: открыты %s\n", green("✓"), strings.Join(opened, " "))
}
if len(failed) > 0 {
errorf(" UFW: не удалось открыть %s — откройте вручную: ufw allow <порт>/tcp", strings.Join(failed, " "))
}
c.restartFresh()
// Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте.
var pin string
for range 50 {
if pin, err = migrate.CertPin(paths["node_tls.crt"]); err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
errorf(" Нет сертификата хаба %s — проверьте лог awg-ui и выдайте строку в панели (сервер #0)", paths["node_tls.crt"])
os.Exit(1)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
fmt.Printf("\n %s Панель перенесена. На старом сервере выполните (строка действует час):\n\n", green("✓"))
fmt.Println(" awg-ctrl join '" + join + "'")
fmt.Println()
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
}
// ── cancel (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateCancel() {
if !c.panelMoved() {
fmt.Println(" Переноса не было: панель на месте")
return
}
if c.isNode {
errorf(" Сервер уже нода новой панели — база там, и вернуть её сюда так нельзя.")
errorf(" Если всё же нужно: awg-ctrl unjoin, затем верните ui.db с новой панели вручную.")
os.Exit(1)
}
content := c.readEnvFile()
for _, k := range []string{"PANEL_MOVED", "RELAY"} {
content = removeEnvVar(content, k)
}
c.writeEnvFile(content)
fmt.Printf(" %s Перенос отменён, панель запускается здесь снова\n", green("✓"))
fmt.Println(" " + dim("Если архив уже импортирован на новую панель — не запускайте там ui, пока она не очищена"))
c.restartFresh()
}
// ── rehome (нода) ──────────────────────────────────────────────────────────
// rehome — панель переехала на новый адрес: тот же pin и id, другой CORE_HOST[:CORE_PORT].
func (c *CLI) rehome(addr string, hasAddr bool) {
if !c.isNode {
errorf(" Это не нода — менять адрес core нечему")
os.Exit(1)
}
host, port := addr, ""
if h, p, err := net.SplitHostPort(addr); err == nil {
host, port = h, p
}
if p, err := strconv.Atoi(port); !hasAddr || !reJoinHost.MatchString(host) || port != "" && (err != nil || p <= 0 || p > 65535) {
errorf(" Usage: awg-ctrl rehome <хост[:порт]> (адрес новой панели; pin и id ноды остаются)")
os.Exit(1)
}
content := updateEnvVar(c.readEnvFile(), "CORE_HOST", host)
if port != "" {
content = updateEnvVar(content, "CORE_PORT", port)
} else {
port = os.Getenv("CORE_PORT")
}
// Адрес, указанный руками, главнее того, что сообщила панель.
c.writeEnvFile(removeEnvVar(content, "CORE_ENDPOINT"))
fmt.Printf(" %s Core: %s:%s\n", green("✓"), host, port)
c.restartFresh()
}
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func decodeJoin(t *testing.T, s string) jsjson.Object {
t.Helper()
v, err := jsjson.Decode(fromB64url(strings.TrimPrefix(s, "awgjoin://")))
o, ok := v.(jsjson.Object)
if err != nil || !ok {
t.Fatalf("join-строка не разбирается: %v", err)
}
return o
}
// export берёт пути и порты из окружения сервисов, как их видит awg-ui.
func TestWriteExportFromEnv(t *testing.T) {
dir := t.TempDir()
sign := filepath.Join(dir, "sign")
os.WriteFile(sign, []byte("sign"), 0o600)
dbFile := filepath.Join(dir, "ui.db")
db, err := migrate.OpenDB(dbFile)
if err != nil {
t.Fatal(err)
}
db.Exec("CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
db.Exec("INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')")
db.Close()
for k, v := range map[string]string{
"AWG_ROOT": dir, "UI_DB_FILE": dbFile, "SUB_SIGN_KEY_FILE": sign, "AWGCTRL_PORT": "1", "SERVER_IP": "198.51.100.1",
"SUB_PORT": "40123", "SUB_TLS": "on", "NODE_PORT": "40500", "UI_PORT": "33000",
"SUB_TLS_KEY_FILE": filepath.Join(dir, "нет"), "SUB_TLS_CERT_FILE": filepath.Join(dir, "нет"),
"NODE_TLS_KEY_FILE": filepath.Join(dir, "нет"), "NODE_TLS_CERT_FILE": filepath.Join(dir, "нет"),
} {
t.Setenv(k, v)
}
out := filepath.Join(dir, "bundle.tar.gz")
if err := New().writeExport(out, dbFile); err != nil {
t.Fatal(err)
}
if st, _ := os.Stat(out); st.Mode().Perm() != 0o600 {
t.Errorf("права архива %v — в нём ключ подписи", st.Mode().Perm())
}
f, _ := os.Open(out)
b, err := migrate.Read(f)
f.Close()
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Str("server_ip") != "198.51.100.1" || b.Str("server_name") != "VPN" ||
b.Num("sub_port") != 40123 || b.Num("node_port") != 40500 || b.Num("ui_port") != 33000 || b.Meta.Get2("sub_tls") != true ||
!b.Has["sub_sign.key"] || b.Has["sub_tls.key"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
}
func TestRelayRoutes(t *testing.T) {
j := decodeJoin(t, migrate.JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123))
t.Setenv("SUB_PORT", "40123")
t.Setenv("NODE_PORT", "40400")
t.Setenv("UI_PORT", "32000")
if got, want := relayRoutes("203.0.113.7", 40500, j), "40123=203.0.113.7:40123,40400=203.0.113.7:40500,32000=203.0.113.7:33100"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
// Строка, выданная панелью заново (без ui/sub): подписка — на тот же порт, панель не пересылаем.
t.Setenv("NODE_PORT", "")
plain := decodeJoin(t, migrate.JoinString("2001:db8::7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 0, 0))
if got, want := relayRoutes("2001:db8::7", 40500, plain), "40123=[2001:db8::7]:40123"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
}
+48 -5
View File
@@ -4,6 +4,8 @@ package cli
// cli/src/monitor.ts). Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не
// ставит, она только просит (через awg-ctrl) или показывает эту команду. Всё слушает
// 127.0.0.1: наружу Prometheus не торчит, а панель читает его по HTTP на той же машине.
// На ноде без своей панели и скрейп, и чтение идут через агента: метрики панели он берёт у
// core по WSS (AGENT_METRICS_PORT), а запросы вкладки «Монитор» пропускает к Prometheus.
import (
"crypto/rand"
@@ -11,6 +13,8 @@ import (
"encoding/hex"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"os/exec"
@@ -121,7 +125,8 @@ func copyFile(src, dst string) {
}
}
func writePromConfig(uiPort string) {
// writePromConfig — target: порт панели на этой машине или агента ноды.
func writePromConfig(target string) {
if exists(promConf) {
copyFile(promConf, promConf+".bak-"+backupStamp())
}
@@ -135,7 +140,7 @@ scrape_configs:
scheme: http
metrics_path: /metrics
`+authBlock()+` static_configs:
- targets: ['127.0.0.1:`+uiPort+`']
- targets: ['127.0.0.1:`+target+`']
`), 0o644)
}
@@ -172,8 +177,22 @@ func (c *CLI) monitorInstall() {
if !reDebian.MatchString(osFamily()) {
mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).")
}
uiPort := os.Getenv("UI_PORT")
if uiPort == "" {
// Нода без своей панели (в том числе сервер, с которого панель переехала): метрики панели
// Prometheus получает от агента, а тот — от core.
viaAgent := c.isNode && !contains(c.all, "ui")
target, newPort := os.Getenv("UI_PORT"), false
if viaAgent {
if target = os.Getenv("AGENT_METRICS_PORT"); target == "" {
if target = freeLocalPort(); target == "" {
mfail("Не нашлось свободного порта для агента")
}
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "AGENT_METRICS_PORT", target)); err != nil {
mfail("Не удалось записать " + c.envFile + ": " + err.Error())
}
c.setSvcEnv("agent", "AGENT_METRICS_PORT", target)
newPort = true
}
} else if target == "" {
mfail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.")
}
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
@@ -195,7 +214,12 @@ func (c *CLI) monitorInstall() {
mlog("2/4 токен и конфиг")
writePromToken()
writePromConfig(uiPort)
writePromConfig(target)
if newPort {
mlog("агент отдаёт метрики панели на 127.0.0.1:" + target + " — перезапуск агента")
c.stop("agent")
c.start("agent")
}
mlog("3/4 слушаем только " + promListen + ", хранение " + promRetention)
setPromArgs()
@@ -208,6 +232,9 @@ func (c *CLI) monitorInstall() {
for range 30 {
if promReady() {
fmt.Printf("\n ✓ Prometheus работает на %s, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n\n", promListen)
if viaAgent {
fmt.Print(" Панель читает его через агента этой ноды.\n\n")
}
return
}
time.Sleep(time.Second)
@@ -215,6 +242,22 @@ func (c *CLI) monitorInstall() {
mfail("Prometheus не ответил за 30 с: journalctl -u prometheus")
}
// freeLocalPort — свободный порт из того же диапазона, что у install.sh (32768–55535).
func freeLocalPort() string {
for range 50 {
n, err := rand.Int(rand.Reader, big.NewInt(55535-32768+1))
if err != nil {
return ""
}
p := strconv.Itoa(32768 + int(n.Int64()))
if l, err := net.Listen("tcp", "127.0.0.1:"+p); err == nil {
l.Close()
return p
}
}
return ""
}
func yesNo(b bool, yes, no string) string {
if b {
return yes
+43
View File
@@ -0,0 +1,43 @@
// Package envfile — правка cli.env (KEY=value построчно): CLI и панель пишут в него одинаково.
package envfile
import (
"os"
"regexp"
"strings"
)
// Update — как String.replace без g: первое KEY=…, а нет его — строка в конец.
func Update(content, key, value string) string {
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
if loc := re.FindStringIndex(content); loc != nil {
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
}
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
}
func Remove(content, key string) string {
var kept []string
for _, l := range strings.Split(content, "\n") {
if !strings.HasPrefix(l, key+"=") {
kept = append(kept, l)
}
}
return strings.Join(kept, "\n")
}
// Set — задать set и убрать remove в файле. Новый файл — 0600 (в нём пароль панели).
func Set(file string, set [][2]string, remove ...string) error {
raw, err := os.ReadFile(file)
if err != nil && !os.IsNotExist(err) {
return err
}
content := string(raw)
for _, kv := range set {
content = Update(content, kv[0], kv[1])
}
for _, k := range remove {
content = Remove(content, k)
}
return os.WriteFile(file, []byte(content), 0o600)
}
+41
View File
@@ -0,0 +1,41 @@
package migrate
import (
"os/exec"
"strconv"
"strings"
)
// ufw — вызов ufw (в тестах подменяется). Нет ufw — ошибка, и правила не трогаются.
var ufw = func(args ...string) ([]byte, error) {
if _, err := exec.LookPath("ufw"); err != nil {
return nil, err
}
return exec.Command("ufw", args...).CombinedOutput()
}
// OpenFirewall — открыть в UFW порты, на которые панель переезжает при приёме: подписка и хаб
// берут порты старого сервера, а install.sh открыл только те, что были при установке. Без этого
// нода #0 не дозвонится до хаба, а ретранслятор — до подписки. UFW не установлен или не активен —
// ничего не делаем (как install.sh). opened — что открыто, failed — что не удалось.
func OpenFirewall(ports ...int) (opened, failed []string) {
out, err := ufw("status")
if err != nil || !strings.Contains(strings.ToLower(string(out)), "status: active") {
return nil, nil
}
for _, p := range ports {
if p <= 0 {
continue
}
rule := strconv.Itoa(p) + "/tcp"
if _, err := ufw("allow", rule); err != nil {
failed = append(failed, rule)
} else {
opened = append(opened, rule)
}
}
if len(opened) > 0 {
ufw("reload")
}
return opened, failed
}
+382
View File
@@ -0,0 +1,382 @@
// Package migrate — перенос панели с сервера «панель + VPN» на отдельную панель без VPN: архив
// (ui.db, ключи подписки и хаба, порты), его раскладка на новой панели и нода #0. Общий для
// CLI (`awg-control migrate`) и вкладки «Перенос» в панели. Порядок и смысл —
// docs/node-protocol.md, «Перенос панели».
//
// Старый сервер остаётся нодой #0: id сервера в ui.db не меняется, поэтому конфиг клиента
// (servers[] и ключи) тот же байт в байт, а пиры на его awg-ctrl уже принадлежат области
// перенесённой панели (sub_meta.panel_id едет вместе с ui.db).
package migrate
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64url = base64.RawURLEncoding
const (
Version = 1
maxFile = 1 << 30 // ui.db крупнее гигабайта — явно не то
JoinTTL = 3600 // как у панели: одноразовый секрет живёт час
)
// File — что едет в архиве: имя внутри архива и переменная пути в окружении awg-ui.
type File struct {
Name, Env string
Required bool
}
var Files = []File{
{"ui.db", "UI_DB_FILE", true},
// 🛑 Ключ подписи и TLS подписки зашиты во все выданные sen://, сертификат хаба — во все
// join-строки и подключённые ноды: без них перенос ломает и ссылки, и ноды.
{"sub_sign.key", "SUB_SIGN_KEY_FILE", false},
{"sub_tls.key", "SUB_TLS_KEY_FILE", false},
{"sub_tls.crt", "SUB_TLS_CERT_FILE", false},
{"node_tls.key", "NODE_TLS_KEY_FILE", false},
{"node_tls.crt", "NODE_TLS_CERT_FILE", false},
}
// Paths — имя файла в архиве → путь на этой машине.
type Paths map[string]string
// PathsFrom — пути из переменных окружения awg-ui (get(env) → путь).
func PathsFrom(get func(env string) string) Paths {
p := Paths{}
for _, f := range Files {
p[f.Name] = get(f.Env)
}
return p
}
// OpenDB — ui.db тем же драйвером, что у awg-ui.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", "file:"+(&url.URL{Path: path}).EscapedPath()+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// ── Архив ───────────────────────────────────────────────────────────────────
// Write — архив в w: снимок db (VACUUM INTO — целостный, вместе с WAL), файлы из p и meta
// (+ panel_id из базы). Отсутствующие необязательные файлы пропускаются.
func Write(w io.Writer, db *sql.DB, p Paths, meta jsjson.Object) error {
tmp, err := os.MkdirTemp("", "forgetting-migrate-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
snap := filepath.Join(tmp, "ui.db")
if _, err := db.Exec("VACUUM INTO ?", snap); err != nil {
return fmt.Errorf("снимок ui.db: %w", err)
}
var panelID string
db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&panelID)
meta = append(jsjson.Object{{Key: "v", Val: Version}, {Key: "panel_id", Val: panelID}}, meta...)
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
add := func(name string, data []byte) error {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
err = add("meta.json", jsjson.MustMarshal(meta))
for _, f := range Files {
if err != nil {
break
}
src := p[f.Name]
if f.Name == "ui.db" {
src = snap
}
raw, rerr := os.ReadFile(src)
if errors.Is(rerr, os.ErrNotExist) && !f.Required {
continue
}
if rerr != nil {
err = rerr
break
}
err = add(f.Name, raw)
}
if cerr := tw.Close(); err == nil {
err = cerr
}
if cerr := gz.Close(); err == nil {
err = cerr
}
return err
}
type Bundle struct {
Dir string
Meta jsjson.Object
Has map[string]bool
}
func (b *Bundle) Close() { os.RemoveAll(b.Dir) }
func (b *Bundle) Num(k string) int {
f, _ := b.Meta.Get2(k).(float64)
return int(f)
}
func (b *Bundle) Str(k string) string {
s, _ := b.Meta.Get2(k).(string)
return s
}
// Read — распаковка во временный каталог; в архиве допустимы только известные имена.
func Read(r io.Reader) (*Bundle, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("не архив переноса: %w", err)
}
dir, err := os.MkdirTemp("", "forgetting-import-")
if err != nil {
return nil, err
}
b := &Bundle{Dir: dir, Has: map[string]bool{}}
fail := func(err error) (*Bundle, error) { b.Close(); return nil, err }
allowed := map[string]bool{"meta.json": true}
for _, f := range Files {
allowed[f.Name] = true
}
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fail(fmt.Errorf("архив повреждён: %w", err))
}
if h.Typeflag != tar.TypeReg || !allowed[h.Name] || b.Has[h.Name] || h.Size > maxFile {
return fail(fmt.Errorf("в архиве лишнее: %q", h.Name))
}
dst, err := os.OpenFile(filepath.Join(dir, h.Name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
_, err = io.Copy(dst, io.LimitReader(tr, maxFile))
if cerr := dst.Close(); err == nil {
err = cerr
}
}
if err != nil {
return fail(err)
}
b.Has[h.Name] = true
}
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
var v any
if err == nil {
v, err = jsjson.Decode(raw)
}
meta, ok := v.(jsjson.Object)
if err != nil || !ok || meta.Get2("v") != float64(Version) || !b.Has["ui.db"] {
return fail(errors.New("не архив переноса или от другой версии (нужны meta.json v1 и ui.db)"))
}
b.Meta = meta
return b, nil
}
// PanelData — сколько в базе того, что перенос затёр бы (мастер-ключи, ноды, ключи API).
func PanelData(file string) (int, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
return 0, nil
}
db, err := OpenDB(file)
if err != nil {
return 0, err
}
defer db.Close()
return Count(db), nil
}
func Count(db *sql.DB) int {
total := 0
for _, t := range []string{"master_keys", "nodes", "api_keys"} {
var n int
// Таблицы может не быть — awg-ui ещё не запускался.
if db.QueryRow("SELECT COUNT(*) FROM "+t).Scan(&n) == nil {
total += n
}
}
return total
}
// ── Раскладка на новой панели ──────────────────────────────────────────────
// Plan — порты и переменные cli.env новой панели. Подписка и хаб — на прежних портах: они в
// ссылках, у нод и в DNS, если адрес — домен.
type Plan struct {
SubPort, NodePort, UIPort int
Host string
Env [][2]string
}
// NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес.
func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) {
p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host}
if p.SubPort == 0 {
p.SubPort = env("SUB_PORT")
}
if p.NodePort == 0 {
p.NodePort = env("NODE_PORT")
}
if p.NodePort == 0 {
return nil, errors.New("приём нод не настроен (NODE_PORT): старый сервер подключится к панели нодой")
}
if p.UIPort != 0 && (p.UIPort == p.SubPort || p.UIPort == p.NodePort) || p.SubPort != 0 && p.SubPort == p.NodePort {
return nil, fmt.Errorf("порты пересекаются: панель %d, подписка %d, хаб %d — смените UI_PORT", p.UIPort, p.SubPort, p.NodePort)
}
for _, kv := range [][2]string{{"SUB_PORT", strconv.Itoa(p.SubPort)}, {"NODE_PORT", strconv.Itoa(p.NodePort)}, {"SUB_HOST", host}} {
if kv[1] != "0" && kv[1] != "" {
p.Env = append(p.Env, kv)
}
}
tlsOn := "off"
if b.Has["sub_tls.crt"] && b.Meta.Get2("sub_tls") == true {
tlsOn = "on"
}
p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn})
// С этого момента панель считает, кто из клиентов и нод узнал её новый адрес.
p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)})
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
if ip := b.Str("server_ip"); ip != "" {
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
return p, nil
}
// TrustRelay — ещё один адрес ретранслятора: старый сервер может выходить в сеть не с того IP,
// что у него в SERVER_IP (NAT, второй адрес). Панель видит, откуда пришёл архив, — его и берём.
func (p *Plan) TrustRelay(ip string) {
if ip == "" {
return
}
for i, kv := range p.Env {
if kv[0] == "RELAY_FROM" {
for _, have := range strings.Split(kv[1], ",") {
if have == ip {
return
}
}
p.Env[i][1] += "," + ip
return
}
}
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
// Install — файлы архива на свои места; прежние уходят в *.bak-<время>.
func Install(b *Bundle, p Paths) error {
stamp := time.Now().Format("20060102-150405")
for _, f := range Files {
if !b.Has[f.Name] {
continue
}
dst := p[f.Name]
olds := []string{dst}
if f.Name == "ui.db" {
olds = append(olds, dst+"-wal", dst+"-shm")
}
for _, o := range olds {
if _, err := os.Stat(o); err == nil {
if err := os.Rename(o, o+".bak-"+stamp); err != nil {
return fmt.Errorf("не удалось отложить %s: %w", o, err)
}
}
}
raw, err := os.ReadFile(filepath.Join(b.Dir, f.Name))
if err == nil {
err = os.MkdirAll(filepath.Dir(dst), 0o700)
}
if err == nil {
err = os.WriteFile(dst, raw, 0o600)
}
if err != nil {
return fmt.Errorf("не удалось записать %s: %w", dst, err)
}
}
return nil
}
// AddNodeZero — старый сервер в реестре нод под прежним id 0, ждёт первого подключения.
func AddNodeZero(dbFile, name string, secret []byte) error {
if name == "" || len([]rune(name)) > 40 || strings.ContainsAny(name, "\r\n<>") {
name = "VPN"
}
db, err := OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
h := sha256.Sum256(secret)
now := time.Now().Unix()
if _, err := db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, ?, ?, ?, ?)",
name, h[:], now+JoinTTL, now); err != nil {
return fmt.Errorf("нода #0 не заведена (база со старой версии? обновите старый сервер): %w", err)
}
return nil
}
// CertPin — SHA-256 SPKI сертификата, base64url: то же, что pin в join-строке.
func CertPin(file string) (string, error) {
raw, err := os.ReadFile(file)
if err != nil {
return "", err
}
blk, _ := pem.Decode(raw)
if blk == nil {
return "", errors.New("не PEM")
}
cert, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64url.EncodeToString(sum[:]), nil
}
// JoinString — как у панели (nodes.go), плюс ui/sub: куда ретранслятору старого сервера слать
// прежние порты панели и подписки. Остальные читатели строки лишних полей не видят.
func JoinString(host string, port int, pin string, id int64, secret []byte, uiPort, subPort int) string {
o := jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: port},
{Key: "pin", Val: pin}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}
if uiPort > 0 {
o = append(o, jsjson.KV{Key: "ui", Val: uiPort})
}
if subPort > 0 {
o = append(o, jsjson.KV{Key: "sub", Val: subPort})
}
return "awgjoin://" + b64url.EncodeToString(jsjson.MustMarshal(o))
}
+205
View File
@@ -0,0 +1,205 @@
package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"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("открылся повреждённый")
}
}
// Приём переноса открывает новые порты в UFW, только если он активен, — как install.sh.
func TestOpenFirewall(t *testing.T) {
var calls []string
status := "Status: active"
defer func(f func(...string) ([]byte, error)) { ufw = f }(ufw)
ufw = func(args ...string) ([]byte, error) {
calls = append(calls, strings.Join(args, " "))
if args[0] == "status" {
return []byte(status), nil
}
if len(args) > 1 && args[1] == "45000/tcp" {
return nil, errors.New("exit 1")
}
return nil, nil
}
opened, failed := OpenFirewall(40222, 45000, 0)
if strings.Join(opened, ",") != "40222/tcp" || strings.Join(failed, ",") != "45000/tcp" {
t.Errorf("opened %v failed %v", opened, failed)
}
if strings.Join(calls, "; ") != "status; allow 40222/tcp; allow 45000/tcp; reload" {
t.Errorf("вызовы: %v", calls)
}
calls, status = nil, "Status: inactive"
if opened, _ := OpenFirewall(40222); opened != nil || len(calls) != 1 {
t.Errorf("UFW выключен, а правила трогаются: %v", calls)
}
}
+115
View File
@@ -0,0 +1,115 @@
package migrate
// Перенос из панели в панель (вкладка «Перенос»): новая панель выдаёт одноразовый код
// awgmove://, старая шлёт по нему архив на её порт панели. Порт панели обычно — голый HTTP,
// а в архиве ключ подписи, поэтому и запрос, и ответ зашифрованы AES-256-GCM ключом из кода:
// без кода архив не прочесть и не подделать, а верный тег и есть доказательство, что код знают.
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
CodePrefix = "awgmove://"
keyLen = 32
// Разные AAD — ответ нельзя выдать за запрос и наоборот.
aadRequest = "awgmove-v1 request"
aadResponse = "awgmove-v1 response"
)
// Code — что старая панель узнаёт из кода: куда слать и чем шифровать.
type Code struct {
Host string
Port int
Key []byte
}
func NewKey() []byte {
k := make([]byte, keyLen)
rand.Read(k)
return k
}
func (c Code) String() string {
return CodePrefix + b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{
{Key: "v", Val: Version}, {Key: "h", Val: c.Host}, {Key: "p", Val: c.Port}, {Key: "k", Val: b64url.EncodeToString(c.Key)}}))
}
func ParseCode(s string) (Code, error) {
bad := errors.New("код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, CodePrefix) {
return Code{}, bad
}
raw, err := b64url.DecodeString(s[len(CodePrefix):])
if err != nil {
return Code{}, bad
}
v, err := jsjson.Decode(raw)
o, ok := v.(jsjson.Object)
if err != nil || !ok || o.Get2("v") != float64(Version) {
return Code{}, bad
}
host, _ := o.Get2("h").(string)
port, _ := o.Get2("p").(float64)
k, _ := o.Get2("k").(string)
key, err := b64url.DecodeString(k)
if host == "" || len(host) > 253 || strings.ContainsAny(host, "/?#@ ") || port <= 0 || port > 65535 ||
port != float64(int(port)) || err != nil || len(key) != keyLen {
return Code{}, bad
}
return Code{Host: host, Port: int(port), Key: key}, nil
}
// URL — куда слать архив.
func (c Code) URL() string {
h := c.Host
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
h = "[" + h + "]"
}
return "http://" + h + ":" + strconv.Itoa(c.Port) + "/migrate/v1/import"
}
func gcm(key []byte) (cipher.AEAD, error) {
blk, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(blk)
}
func seal(key []byte, aad string, plain []byte) []byte {
g, err := gcm(key)
if err != nil {
panic(err) // ключ всегда 32 байта
}
nonce := make([]byte, g.NonceSize())
rand.Read(nonce)
return g.Seal(nonce, nonce, plain, []byte(aad))
}
var ErrSeal = errors.New("не расшифровать: код не тот или данные повреждены")
func open(key []byte, aad string, box []byte) ([]byte, error) {
g, err := gcm(key)
if err != nil || len(box) < g.NonceSize() {
return nil, ErrSeal
}
plain, err := g.Open(nil, box[:g.NonceSize()], box[g.NonceSize():], []byte(aad))
if err != nil {
return nil, ErrSeal
}
return plain, nil
}
func SealRequest(key, plain []byte) []byte { return seal(key, aadRequest, plain) }
func OpenRequest(key, box []byte) ([]byte, error) { return open(key, aadRequest, box) }
func SealResponse(key, plain []byte) []byte { return seal(key, aadResponse, plain) }
func OpenResponse(key, box []byte) ([]byte, error) { return open(key, aadResponse, box) }
+159
View File
@@ -0,0 +1,159 @@
// Package relay — ретранслятор TCP на сервере, с которого переехала панель (`awg-control
// migrate`). В выданных ссылках sen://, у подключённых нод и у клиентов внешнего API остался
// старый адрес: relay слушает прежние порты подписки, хаба нод и панели и пересылает соединения
// на новую панель как есть — TLS идёт насквозь, ключи и сертификаты у панели прежние. Перед
// данными идёт заголовок PROXY v1 с адресом клиента: панель верит ему только от RELAY_FROM.
package relay
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
)
const dialTimeout = 10 * time.Second
// Route — одна пересылка: локальный порт → адрес новой панели.
type Route struct {
Listen int
Target string // host:port
}
// ParseRoutes — RELAY: «порт=хост:порт» через запятую.
func ParseRoutes(s string) ([]Route, error) {
var out []Route
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
l, t, ok := strings.Cut(part, "=")
port, err := strconv.Atoi(l)
if !ok || err != nil || port <= 0 || port > 65535 {
return nil, fmt.Errorf("relay: неверная пересылка %q (нужно порт=хост:порт)", part)
}
host, tp, err := net.SplitHostPort(t)
if n, perr := strconv.Atoi(tp); err != nil || host == "" || perr != nil || n <= 0 || n > 65535 {
return nil, fmt.Errorf("relay: неверный адрес %q", t)
}
out = append(out, Route{port, t})
}
if len(out) == 0 {
return nil, errors.New("relay: RELAY пуст — пересылать нечего")
}
return out, nil
}
// proxyHeader — строка PROXY v1 для соединения c.
func proxyHeader(c net.Conn) string {
src, ok1 := c.RemoteAddr().(*net.TCPAddr)
dst, ok2 := c.LocalAddr().(*net.TCPAddr)
if !ok1 || !ok2 {
return "PROXY UNKNOWN\r\n"
}
sip, dip := src.IP, dst.IP
fam := "TCP6"
if s4, d4 := sip.To4(), dip.To4(); s4 != nil && d4 != nil {
fam, sip, dip = "TCP4", s4, d4
} else if s4 != nil || d4 != nil {
// Смешанные семейства (v4-mapped на v6-сокете и т. п.) — оба в v6.
sip, dip = sip.To16(), dip.To16()
}
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, sip, dip, src.Port, dst.Port)
}
// pipe — копирование в обе стороны; конец одной стороны закрывает запись в другую.
func pipe(a, b net.Conn) {
var wg sync.WaitGroup
cp := func(dst, src net.Conn) {
defer wg.Done()
io.Copy(dst, src)
if tc, ok := dst.(*net.TCPConn); ok {
tc.CloseWrite()
} else {
dst.Close()
}
}
wg.Add(2)
go cp(a, b)
go cp(b, a)
wg.Wait()
a.Close()
b.Close()
}
func serve(ctx context.Context, ln net.Listener, r Route, errorf func(string, ...any)) {
var d net.Dialer
d.Timeout = dialTimeout
for {
c, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
errorf("relay: :%d: %v", r.Listen, err)
time.Sleep(time.Second)
continue
}
go func() {
up, err := d.DialContext(ctx, "tcp", r.Target)
if err != nil {
errorf("relay: :%d → %s: %v", r.Listen, r.Target, err)
c.Close()
return
}
if _, err := io.WriteString(up, proxyHeader(c)); err != nil {
c.Close()
up.Close()
return
}
pipe(c, up)
}()
}
}
// Run слушает все порты и пересылает до отмены ctx. Порт, который не открылся, — ошибка сразу:
// значит, его ещё держит прежняя панель.
func Run(ctx context.Context, routes []Route, logf, errorf func(string, ...any)) error {
var lns []net.Listener
for _, r := range routes {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(r.Listen))
if err != nil {
for _, l := range lns {
l.Close()
}
return fmt.Errorf("relay: порт %d: %w", r.Listen, err)
}
lns = append(lns, ln)
logf("relay: :%d → %s", r.Listen, r.Target)
}
for i, r := range routes {
go serve(ctx, lns[i], r, errorf)
}
<-ctx.Done()
for _, l := range lns {
l.Close()
}
return ctx.Err()
}
// Main — роль `awg-control relay`: пересылки из RELAY.
func Main(ctx context.Context) error {
routes, err := ParseRoutes(os.Getenv("RELAY"))
if err != nil {
return err
}
logf := func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
errorf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
return Run(ctx, routes, logf, errorf)
}
+102
View File
@@ -0,0 +1,102 @@
package relay
import (
"bufio"
"context"
"io"
"net"
"strconv"
"strings"
"testing"
"time"
)
func TestParseRoutes(t *testing.T) {
r, err := ParseRoutes(" 40123=203.0.113.7:40123, 8443=[2001:db8::1]:9000 ,")
if err != nil || len(r) != 2 || r[0] != (Route{40123, "203.0.113.7:40123"}) || r[1] != (Route{8443, "[2001:db8::1]:9000"}) {
t.Fatalf("ParseRoutes: %v %v", r, err)
}
for _, bad := range []string{"", ",", "40123", "0=h:1", "70000=h:1", "1=h", "1=:5", "1=h:0", "x=h:1"} {
if _, err := ParseRoutes(bad); err == nil {
t.Errorf("ParseRoutes(%q): ожидали ошибку", bad)
}
}
}
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
// Поток идёт насквозь в обе стороны, первой строкой у панели — PROXY с адресом клиента.
func TestRelayForwardsWithProxyHeader(t *testing.T) {
target, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer target.Close()
got := make(chan string, 2)
go func() {
c, err := target.Accept()
if err != nil {
return
}
defer c.Close()
r := bufio.NewReader(c)
hdr, _ := r.ReadString('\n')
got <- hdr
body, _ := io.ReadAll(r)
got <- string(body)
io.WriteString(c, "pong")
}()
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errs := make(chan error, 1)
go func() {
errs <- Run(ctx, []Route{{port, target.Addr().String()}}, func(string, ...any) {}, func(f string, a ...any) { t.Logf(f, a...) })
}()
var cl net.Conn
for range 50 {
if cl, err = net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))); err == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, "ping")
cl.(*net.TCPConn).CloseWrite()
hdr := <-got
src := cl.LocalAddr().(*net.TCPAddr)
if want := "PROXY TCP4 127.0.0.1 127.0.0.1 " + strconv.Itoa(src.Port) + " " + strconv.Itoa(port) + "\r\n"; hdr != want {
t.Errorf("заголовок %q, ожидали %q", hdr, want)
}
if body := <-got; body != "ping" {
t.Errorf("до панели дошло %q", body)
}
cl.SetReadDeadline(time.Now().Add(3 * time.Second))
if back, _ := io.ReadAll(cl); string(back) != "pong" {
t.Errorf("обратно пришло %q", back)
}
// Занятый порт — ошибка сразу: значит, его держит прежняя панель.
busy, _ := net.Listen("tcp", ":0")
defer busy.Close()
if err := Run(context.Background(), []Route{{busy.Addr().(*net.TCPAddr).Port, "127.0.0.1:1"}}, func(string, ...any) {}, t.Logf); err == nil ||
!strings.Contains(err.Error(), "порт") {
t.Errorf("занятый порт: %v", err)
}
cancel()
<-errs
}
+104
View File
@@ -0,0 +1,104 @@
package ui
import (
"errors"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
// «Серверы для всех»: add — у каждого живого ключа, remove — ни у кого, остальное не трогается;
// пиры снятых серверов — в надгробия, новые ключи получают набор по умолчанию.
func TestBulkServers(t *testing.T) {
p := newMovePanel(t, "off", "40500")
s, db := p.s, p.a.db
if err := p.a.initAPIKeys(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'A', 'x', 1), (2, 'B', 'x', 1), (3, 'C', 'x', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k1', x'01', 1), (2, 'u2', 'k2', x'02', 1), (3, 'u3', 'k3', x'03', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (4, 'u4', 'k4', x'04', 1, 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 1), (2, 1), (2, 2), (3, 2), (4, 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 2, 'd', 'W', 'A', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 1, 'P1', '10.0.0.2', 'K'), (5, 2, 'P2', '10.0.1.2', 'K')",
"INSERT INTO api_keys (id, label, key_hash, prefix, created_at) VALUES (1, 'Бот', 'h1', 'p1', 1), (2, 'Всё', 'h2', 'p2', 1)",
"INSERT INTO api_key_servers (key_id, server_id) VALUES (1, 1), (1, 2), (2, 1), (2, 2), (2, 3)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(q, err)
}
}
servers := func() string {
var out []string
for _, id := range []int64{1, 2, 3, 4} {
out = append(out, string(jsjson.MustMarshal(intsJSON(s.serverIDsOf(db, id)))))
}
return strings.Join(out, " ")
}
body := func(kv ...any) jsjson.Object {
o := jsjson.Object{}
for i := 0; i < len(kv); i += 2 {
o = append(o, jsjson.KV{Key: kv[i].(string), Val: kv[i+1]})
}
return o
}
f := func(ids ...float64) []any {
out := []any{}
for _, x := range ids {
out = append(out, x)
}
return out
}
status := func(err error) int {
var ae *apiError
if errors.As(err, &ae) {
return ae.status
}
return 0
}
out, err := s.bulkServers(body("add", f(3), "remove", f(2), "dry_run", true))
if err != nil {
t.Fatal(err)
}
if got := string(jsjson.MustMarshal(out)); got != `{"changed":3,"total":3,"hidden":[{"id":1,"label":"Бот","keys":3}]}` {
t.Errorf("предпросмотр: %s", got)
}
if got := servers(); got != "[1] [1,2] [2] [1]" {
t.Errorf("предпросмотр изменил ключи: %s", got)
}
if _, err := s.bulkServers(body("remove", f(1, 2))); status(err) != 409 {
t.Errorf("ключи без серверов: %v", err)
}
if _, err := s.bulkServers(body("add", f(2), "remove", f(2))); status(err) != 400 {
t.Errorf("и у всех, и ни у кого: %v", err)
}
if _, err := s.bulkServers(body("add", f(9))); status(err) != 400 {
t.Errorf("несуществующий сервер: %v", err)
}
if _, err := s.bulkServers(body("add", f(3), "remove", f(2), "defaults", f(3))); err != nil {
t.Fatal(err)
}
if got := servers(); got != "[1,3] [1,3] [3] [1]" {
t.Errorf("после применения: %s (удаляемый ключ трогать нельзя)", got)
}
var n int
db.QueryRow("SELECT COUNT(*) FROM peer_tombstones WHERE server_id = 2 AND pub_key = 'P2'").Scan(&n)
if n != 1 {
t.Error("пир снятого сервера не в надгробиях")
}
db.QueryRow("SELECT COUNT(*) FROM device_peers WHERE device_id = 5 AND server_id = 3").Scan(&n)
if n != 1 {
t.Error("устройство не получило заявку на новом сервере")
}
m, err := s.createMaster(body("label", "Новый"), nil)
if err != nil || string(jsjson.MustMarshal(m.Get2("servers"))) != "[3]" {
t.Errorf("новый ключ: %v %v", m, err)
}
if _, err := s.bulkServers(body("defaults", nil)); err != nil || s.defaultServers() != nil {
t.Errorf("сброс набора по умолчанию: %v %v", s.defaultServers(), err)
}
}
+458
View File
@@ -0,0 +1,458 @@
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)
}
// Подписка и хаб переезжают на порты старого сервера — их надо открыть, как install.sh.
if opened, failed := migrate.OpenFirewall(plan.SubPort, plan.NodePort); len(opened)+len(failed) > 0 {
logf("migrate: UFW открыт %v, не открылись %v", opened, failed)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
m.state = moveImported
logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP())
m.run("restart")
resp := jsjson.MustMarshal(jsjson.Object{{Key: "join", Val: join}})
c.W.Header().Set("Content-Type", "application/octet-stream")
c.W.WriteHeader(200)
c.W.Write(migrate.SealResponse(key, resp))
return false
}
// ── Старая панель ───────────────────────────────────────────────────────────
func (m *Migration) export(c *Ctx) bool {
if why := m.canExport(); why != "" {
return c.Error(409, why)
}
codeStr, _ := c.Obj().Get2("code").(string)
code, err := migrate.ParseCode(codeStr)
if err != nil {
return c.Error(400, err.Error())
}
m.mu.Lock()
if m.state != moveIdle {
m.mu.Unlock()
return c.Error(409, "Перенос уже идёт")
}
m.state, m.errText = moveSending, ""
m.mu.Unlock()
end := func(state, errText string) {
m.mu.Lock()
m.state, m.errText = state, errText
m.mu.Unlock()
m.a.sub.frozen.Store(state != moveIdle)
}
// Пока архив в пути, подписка отвечает «недоступна» (клиенты держат туннель и повторят):
// регистрация или смена ключа сейчас остались бы только в старой базе.
m.a.sub.frozen.Store(true)
var buf bytes.Buffer
if err := migrate.Write(&buf, m.a.db, m.paths(), m.exportMeta()); err != nil {
end(moveIdle, "")
return c.Error(500, "Архив не собран: "+err.Error())
}
logf("migrate: архив %d КБ → %s", buf.Len()/1024, code.URL())
cl := &http.Client{Timeout: moveTimeout, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Post(code.URL(), "application/octet-stream", bytes.NewReader(migrate.SealRequest(code.Key, buf.Bytes())))
if err != nil {
// Не дозвонились — новая точно ничего не получила. Иначе архив мог дойти.
var op *net.OpError
if errors.As(err, &op) && op.Op == "dial" {
end(moveIdle, "")
return c.Error(502, "Новая панель не отвечает: "+err.Error())
}
return m.uncertain(c, end, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != 200 {
end(moveIdle, "")
msg := "Новая панель отказала (" + strconv.Itoa(resp.StatusCode) + ")"
if v, derr := jsjson.Decode(raw); derr == nil {
if e, ok := field(v, "error").(string); ok {
msg = "Новая панель: " + e
}
}
return c.Error(502, msg)
}
var plain []byte
if err == nil {
plain, err = migrate.OpenResponse(code.Key, raw)
}
var join string
if err == nil {
v, derr := jsjson.Decode(plain)
join, _ = field(v, "join").(string)
if derr != nil || !strings.HasPrefix(join, "awgjoin://") {
err = errors.New("ответ без join-строки")
}
}
if err != nil {
return m.uncertain(c, end, err)
}
if err := envfile.Set(m.envFile, [][2]string{{"PANEL_MOVED", "on"}}); err != nil {
return m.uncertain(c, end, err)
}
target := strings.TrimSuffix(code.URL(), "/migrate/v1/import")
m.mu.Lock()
m.target = target
m.mu.Unlock()
end(moveMoved, "")
logf("migrate: новая панель приняла перенос — join, этот сервер станет нодой #0")
m.run("join", join)
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}, {Key: "target", Val: target}})
}
// uncertain — архив ушёл, а подтверждения нет. Подписка остаётся замороженной: если новая
// панель его приняла, вторая живая копия базы разошлась бы с ней.
func (m *Migration) uncertain(c *Ctx, end func(string, string), err error) bool {
msg := "Неизвестно, принят ли перенос (" + err.Error() + "). Откройте новую панель: если там есть сервер #0 — " +
"выполните здесь `awg-ctrl join` со строкой из его карточки; если нет — «Отменить перенос»."
end(moveUncertain, msg)
warnf("migrate: %s", msg)
return c.Error(502, msg)
}
func (m *Migration) cancel(c *Ctx) bool {
m.mu.Lock()
ok := m.state == moveUncertain
if ok {
m.state, m.errText = moveIdle, ""
}
m.mu.Unlock()
if !ok {
return c.Error(409, "Отменять нечего")
}
m.a.sub.frozen.Store(false)
logf("migrate: перенос отменён — подписка снова работает")
return m.status(c)
}
func (m *Migration) exportMeta() jsjson.Object {
name, ip := "VPN", os.Getenv("SERVER_IP")
if r, err := m.a.ctrlLocal("GET", "/health", nil); err == nil {
if s, ok := field(r.Data, "server").(string); ok && s != "" {
name = s
}
if s, ok := field(r.Data, "ip").(string); ok && ip == "" {
ip = s
}
}
return jsjson.Object{
{Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS},
{Key: "node_port", Val: m.a.nodes.port},
}
}
// spawn — `awg-control <args>` отдельным процессом через секунду (ответ успеет уйти). Окружение —
// чистое: CLI читает cli.env заново, а унаследованные значения перекрыли бы новые.
func (m *Migration) spawn(args ...string) {
exe, err := os.Executable()
if err != nil {
warnf("migrate: %v", err)
return
}
var env []string
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
if k == "PATH" || k == "HOME" || k == "LANG" || k == "TZ" || k == "AWG_ROOT" || strings.HasPrefix(k, "LC_") {
env = append(env, kv)
}
}
logFile := filepath.Join(filepath.Dir(m.a.baseDir), "logs", "awg-migrate.log")
go func() {
time.Sleep(time.Second)
cmd := exec.Command(exe, args...)
cmd.Env, cmd.Dir = env, "/"
if f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
cmd.Stdout, cmd.Stderr = f, f
defer f.Close()
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // переживёт эту панель
if err := cmd.Start(); err != nil {
warnf("migrate: awg-control %s: %v", args[0], err)
return
}
cmd.Wait()
}()
}
+174
View File
@@ -0,0 +1,174 @@
package ui
import (
"bufio"
"io"
"net"
"path/filepath"
"strings"
"testing"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testApp(t *testing.T, dir, local string) *App {
t.Helper()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
return a
}
// Перенос панели (`awg-control migrate`): та же ui.db на панели без VPN, старый сервер — нода
// #0. Клиент должен получить тот же конфиг байт в байт, вместе с rev, иначе он пересоздал бы
// туннель, а сервис на macOS — ещё и ключ.
func TestMigratedConfigIsByteIdentical(t *testing.T) {
oldDir, newDir := t.TempDir(), t.TempDir()
old := testApp(t, oldDir, "on")
s := &Sub{a: old, host: "203.0.113.7", port: 40123}
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (1, 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'Нода', 'pub', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0'), (5, 1, 'WG', '10.9.0.9', 'PSK1')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES
(0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0","dns":["1.1.1.1"],"mtu":1376,"gen":"3.1","awg":{"Jc":"4"}}', 1),
(1, '{"name":"Нода","endpoint":"192.0.2.2:47619","server_pub":"S1","gen":"2"}', 1)`,
} {
if _, err := old.db.Exec(q); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
dev := s.deviceByID(old.db, 5)
before := jsjson.MustMarshal(s.buildConfig(dev, nil))
if !strings.Contains(string(before), `"id":0`) || !strings.Contains(string(before), `"id":1`) {
t.Fatalf("в конфиге до переноса нет обоих серверов: %s", before)
}
if _, err := old.db.Exec("VACUUM INTO ?", filepath.Join(newDir, "ui.db")); err != nil {
t.Fatal(err)
}
// Как migrate import: нода #0 ещё не подключалась.
pre := testApp(t, newDir, "off")
if _, err := pre.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, 'VPN', x'00', 9999999999, 1)"); err != nil {
t.Fatal(err)
}
moved := testApp(t, newDir, "off")
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if s2.panelID != s.panelID {
t.Errorf("panel_id: %s → %s — пиры на старом сервере остались бы без хозяина", s.panelID, s2.panelID)
}
after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(moved.db, 5), nil))
if string(before) != string(after) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) || moved.nodes.IsOnline(0) || moved.nodes.DefaultID() != 0 {
t.Errorf("нода #0: exists=%v online=%v default=%d", moved.nodes.Exists(0), moved.nodes.IsOnline(0), moved.nodes.DefaultID())
}
if ids := moved.nodes.IDs(); len(ids) != 2 || ids[0] != 0 || ids[1] != 1 {
t.Errorf("IDs = %v", ids)
}
if r, _ := moved.nodes.CtrlFor(0)("GET", "/health", nil); r.Status != 502 {
t.Errorf("нода #0 не на связи: %d, ожидали 502", r.Status)
}
if l := moved.nodes.List(); len(l) != 2 || l[0].Local || !l[0].Pending {
t.Errorf("List: %+v", l)
}
// Без SUB_HOST адрес подписки — не endpoint ноды #0: это другая машина.
s2.host = ""
if h := s2.subHost(nil); h != "" {
t.Errorf("subHost = %q, ожидали пусто", h)
}
// Свой VPN и нода #0 — один id: такую базу с LOCAL_NODE=on не открываем.
t.Setenv("LOCAL_NODE", "on")
if _, err := newNodes(&App{db: moved.db, baseDir: newDir}); err == nil {
t.Error("LOCAL_NODE=on при ноде #0: ожидали ошибку")
}
}
func TestParseProxyV1(t *testing.T) {
for line, want := range map[string]string{
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "192.0.2.1:51000",
"PROXY TCP6 2001:db8::1 2001:db8::2 51000 40123": "[2001:db8::1]:51000",
"PROXY UNKNOWN": "unknown",
"PROXY UNKNOWN ffff::1 ffff::2 1 2": "unknown",
"PROXY TCP4 2001:db8::1 198.51.100.2 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 70000 1": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 1": "",
"PROXY TCP5 192.0.2.1 198.51.100.2 1 2": "",
"GET / HTTP/1.1": "",
"PROXY TCP4 192.0.2.1 not-an-ip 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "",
} {
addr, ok := parseProxyV1(line)
got := ""
switch {
case ok && addr == nil:
got = "unknown"
case ok:
got = addr.String()
}
if got != want {
t.Errorf("%q → %q, ожидали %q", line, got, want)
}
}
}
// Заголовок PROXY — только от доверенного адреса; соединение с него без заголовка проходит как
// есть, а от остальных заголовок остаётся байтами потока.
func TestProxyListener(t *testing.T) {
try := func(trusted bool, send string) (remote, data string) {
t.Helper()
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer raw.Close()
ln := newProxyListener(raw, map[string]bool{"127.0.0.1": trusted})
cl, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, send)
cl.(*net.TCPConn).CloseWrite()
done := make(chan net.Conn, 1)
go func() { c, _ := ln.Accept(); done <- c }()
select {
case c := <-done:
defer c.Close()
b, _ := io.ReadAll(bufio.NewReader(c))
return c.RemoteAddr().String(), string(b)
case <-time.After(3 * time.Second):
t.Fatal("Accept не вернулся")
}
return "", ""
}
if r, d := try(true, "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"); r != "192.0.2.1:51000" || d != "hello" {
t.Errorf("доверенный с заголовком: %s %q", r, d)
}
if r, d := try(true, "GET / HTTP/1.1\r\n"); !strings.HasPrefix(r, "127.0.0.1:") || d != "GET / HTTP/1.1\r\n" {
t.Errorf("доверенный без заголовка: %s %q", r, d)
}
hdr := "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"
if r, d := try(false, hdr); !strings.HasPrefix(r, "127.0.0.1:") || d != hdr {
t.Errorf("чужой с заголовком: %s %q", r, d)
}
}
+168
View File
@@ -0,0 +1,168 @@
package ui
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
type movePanel struct {
a *App
s *Sub
env string
calls [][]string
}
func newMovePanel(t *testing.T, local, nodePort string) *movePanel {
t.Helper()
dir := t.TempDir()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", nodePort)
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir, port: 34001}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
a.sub = &Sub{a: a, host: "203.0.113.7", port: 40123, locks: map[int]*sync.Mutex{}}
if err := a.sub.initSchema(); err != nil {
t.Fatal(err)
}
p := &movePanel{a: a, s: a.sub, env: filepath.Join(dir, "cli.env")}
a.migration = newMigration(a)
a.migration.envFile = p.env
a.migration.run = func(args ...string) { p.calls = append(p.calls, args) }
return p
}
func (p *movePanel) call(t *testing.T, h handler, body jsjson.Object) (int, jsjson.Object) {
t.Helper()
w := httptest.NewRecorder()
h(&Ctx{W: w, R: httptest.NewRequest("POST", "/", nil), Body: body})
v, _ := jsjson.Decode(w.Body.Bytes())
o, _ := v.(jsjson.Object)
return w.Code, o
}
// Вкладка «Перенос» целиком: код новой панели → архив из старой по HTTP → нода #0, join и
// перезапуск. У клиента конфиг тот же, у старой панели подписка заморожена до join.
func TestMigrateBetweenPanels(t *testing.T) {
old := newMovePanel(t, "on", "")
os.WriteFile(filepath.Join(old.a.baseDir, "sub_sign.key"), []byte("ключ подписи"), 0o600)
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0"}', 1)`,
} {
if _, err := old.a.db.Exec(q); err != nil {
t.Fatal(err)
}
}
before := jsjson.MustMarshal(old.s.buildConfig(old.s.deviceByID(old.a.db, 5), nil))
fresh := newMovePanel(t, "off", "40500")
m := fresh.a.migration
if why := m.canAccept(); why != "" {
t.Fatalf("чистая панель без VPN не принимает: %s", why)
}
if why := old.a.migration.canAccept(); why == "" {
t.Error("панель со своим VPN принимает перенос")
}
if why := fresh.a.migration.canExport(); why == "" {
t.Error("панель без VPN отдаёт перенос")
}
rt := newRouter()
rt.Post("/migrate/v1/import", m.importHandler)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, raw, _, _ := readBody(r, rt.bodyLimit)
rt.routes[0].hs[0](&Ctx{W: w, R: r, Body: body, RawBody: raw})
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port, _ := strconv.Atoi(u.Port())
m.key, m.host, m.expires = migrate.NewKey(), "127.0.0.1", nowFn().Add(moveCodeTTL)
// Чужой ключ: архив не принят, код живёт до пятой неудачи.
wrong := migrate.Code{Host: "127.0.0.1", Port: port, Key: migrate.NewKey()}.String()
if st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: wrong}}); st != 502 || !strings.Contains(o.Get2("error").(string), "Код не подходит") {
t.Fatalf("чужой код: %d %v", st, o)
}
if old.s.frozen.Load() || m.key == nil {
t.Fatal("после отказа подписка заморожена или код сгорел")
}
code := migrate.Code{Host: "127.0.0.1", Port: port, Key: m.key}.String()
st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: code}})
if st != 200 || o.Get2("target") != "http://127.0.0.1:"+u.Port() {
t.Fatalf("export: %d %v", st, o)
}
if !old.s.frozen.Load() {
t.Error("старая подписка не заморожена до join")
}
if len(old.calls) != 1 || old.calls[0][0] != "join" {
t.Fatalf("старая панель: %v", old.calls)
}
j, _ := jsjson.Decode(fromB64url(strings.TrimPrefix(old.calls[0][1], "awgjoin://")))
if field(j, "n") != 0.0 || field(j, "p") != 40500.0 || field(j, "sub") != 40123.0 || field(j, "ui") != 34001.0 {
t.Errorf("join: %s", jsjson.MustMarshal(j))
}
if raw, _ := os.ReadFile(old.env); !strings.Contains(string(raw), "PANEL_MOVED=on") {
t.Errorf("старый cli.env: %q", raw)
}
if len(fresh.calls) != 1 || fresh.calls[0][0] != "restart" || m.state != moveImported || m.key != nil {
t.Errorf("новая панель: %v, state %q", fresh.calls, m.state)
}
raw, _ := os.ReadFile(fresh.env)
for _, want := range []string{"SUB_PORT=40123", "NODE_PORT=40500", "SUB_HOST=127.0.0.1", "RELAY_FROM=127.0.0.1"} {
if !strings.Contains(string(raw), want) {
t.Errorf("новый cli.env без %s: %q", want, raw)
}
}
if k, _ := os.ReadFile(filepath.Join(fresh.a.baseDir, "sub_sign.key")); string(k) != "ключ подписи" {
t.Errorf("ключ подписи: %q", k)
}
// Новая панель после перезапуска: та же база + нода #0 → тот же конфиг.
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(fresh.a.baseDir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
moved := &App{db: db, baseDir: fresh.a.baseDir}
if moved.nodes, err = newNodes(moved); err != nil {
t.Fatal(err)
}
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(db, 5), nil)); string(after) != string(before) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) {
t.Error("нет ноды #0")
}
// Повторный приём: кода уже нет.
if st, _ := old.call(t, func(c *Ctx) bool { old.a.migration.state = moveIdle; return old.a.migration.export(c) },
jsjson.Object{{Key: "code", Val: code}}); st != 502 {
t.Errorf("второй перенос тем же кодом: %d", st)
}
}
+155 -39
View File
@@ -2,10 +2,12 @@ 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/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 ходит только фиксированными запросами.
// Prometheus — на этой машине или на ноде (панель без VPN, например после переноса): тогда и
// запросы к нему, и его скрейп метрик панели идут через агента ноды по её WSS.
import (
"errors"
@@ -80,8 +82,22 @@ type Monitor struct {
promURL string
localEnabled bool
http *http.Client
srcMu sync.Mutex
src *promSource // найденный Prometheus; nil — ещё не искали или не нашли
srcAt time.Time
}
// promSource — где Prometheus: local — на этой машине, иначе на ноде node (через её агента).
type promSource struct {
local bool
node int
}
// Найденный Prometheus помним недолго; ненайденный ищем при каждом запросе (вкладка
// запрашивает статус редко, а во время установки должна увидеть его сразу).
const promSourceTTL = 30 * time.Second
func newMonitor(a *App) *Monitor {
return &Monitor{
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
@@ -92,14 +108,72 @@ func newMonitor(a *App) *Monitor {
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))
// source — свой Prometheus, если он есть; иначе первый по id сервер, чей агент отдаёт свой.
func (m *Monitor) source() *promSource {
m.srcMu.Lock()
if m.src != nil && time.Since(m.srcAt) < promSourceTTL {
s := m.src
m.srcMu.Unlock()
return s
}
m.srcMu.Unlock()
s := m.findSource()
m.srcMu.Lock()
m.src, m.srcAt = s, time.Now()
m.srcMu.Unlock()
return s
}
// forgetSource — Prometheus поставили, удалили или он перестал отвечать: искать заново.
func (m *Monitor) forgetSource() {
m.srcMu.Lock()
m.src = nil
m.srcMu.Unlock()
}
func (m *Monitor) findSource() *promSource {
if m.promReady() {
return &promSource{local: true}
}
ids := m.a.nodes.IDs()
ready := make([]bool, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
// Сервер 0 со своим awg-ctrl — эта же машина: её Prometheus уже проверен выше.
if id == 0 && m.localEnabled || !m.a.nodes.IsOnline(id) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
r, err := m.a.nodes.CtrlFor(id)("GET", "/prom/-/ready", nil)
ready[i] = err == nil && r.Status == 200
}()
}
wg.Wait()
for i, id := range ids {
if ready[i] {
return &promSource{node: id}
}
}
return nil
}
// promGet — JSON-ответ Prometheus (path с запросом): напрямую или через агента ноды.
func (m *Monitor) promGet(src *promSource, path string, q url.Values) (any, error) {
if src == nil {
return nil, errProm
}
path += "?" + q.Encode()
if !src.local {
r, err := m.a.nodes.CtrlFor(src.node)("GET", "/prom"+path, nil)
if err != nil || r.Status < 200 || r.Status >= 300 {
return nil, errProm
}
return r.Data, nil
}
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode())
resp, err := c.Get(m.promURL + path)
if err != nil {
return nil, errProm
}
@@ -108,8 +182,21 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errProm
}
return decodeLikeAxios(raw), nil
}
func (m *Monitor) rangeQuery(src *promSource, 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))
data, err := m.promGet(src, "/api/v1/query_range", q)
if err != nil {
return nil, err
}
out := &series{m: map[int]*seriesOne{}}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
for _, r := range arr(field(data, "data", "result")) {
metric := field(r, "metric")
idf := jsToNumberAny(field(metric, "server_id"))
if field(metric, "server_id") == nil {
@@ -146,22 +233,16 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
}
// instantQuery — значение на момент time по каждому server_id.
func (m *Monitor) instantQuery(query string, time_ int64) (map[int]float64, error) {
func (m *Monitor) instantQuery(src *promSource, 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())
data, err := m.promGet(src, "/api/v1/query", q)
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
return nil, err
}
out := map[int]float64{}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
for _, r := range arr(field(data, "data", "result")) {
metric := field(r, "metric")
if field(metric, "server_id") == nil {
continue
@@ -228,12 +309,43 @@ type monitorJob struct {
log string
}
// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут.
func (m *Monitor) jobState() monitorJob {
if !m.localEnabled {
// target — чей awg-ctrl ставит и удаляет Prometheus: того сервера, где он уже стоит; если его
// нет — свой (панель + VPN) или выбранный в сервер-баре (панель без VPN). -1 — некому: например,
// Prometheus на машине панели без VPN поставлен руками.
func (m *Monitor) target(src *promSource, qs url.Values) int {
switch {
case src != nil && !src.local:
return src.node
case m.localEnabled:
return 0
case src != nil:
return -1
}
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) && f >= 0 && m.a.nodes.Exists(int(f)) {
return int(f)
}
return -1
}
// host — сервер target, если это не машина панели (её вкладка так и называет: «этот сервер»).
func (m *Monitor) host(target int) any {
if target < 0 || target == 0 && m.localEnabled {
return nil
}
for _, s := range m.a.nodes.List() {
if s.ID == target {
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}}
}
}
return nil
}
// jobState — установка возможна, только если у target есть awg-ctrl и он знает маршрут.
func (m *Monitor) jobState(target int) monitorJob {
if target < 0 {
return monitorJob{}
}
r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil)
r, err := m.a.nodes.CtrlFor(target)("GET", "/api/monitor/job", nil)
if err != nil || r.Status != 200 {
return monitorJob{}
}
@@ -251,27 +363,26 @@ func (m *Monitor) jobState() monitorJob {
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()
src := m.source()
ready := src != nil
target := m.target(src, c.R.URL.Query())
job := m.jobState(target)
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"}})
{Key: "command", Val: "awg-ctrl monitor install"}, {Key: "host", Val: m.host(target)}})
})
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
// Установка и удаление — одно и то же: просим awg-ctrl сервера, он запускает CLI от root.
ask := func(action string) handler {
return func(c *Ctx) bool {
if !m.localEnabled {
return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине")
target := m.target(m.source(), c.R.URL.Query())
if target < 0 {
return c.Error(409, "Некому поставить Prometheus: выберите VPN-сервер или выполните команду на машине")
}
r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil)
m.forgetSource()
r, err := m.a.nodes.CtrlFor(target)("POST", "/api/monitor/"+action, nil)
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
@@ -294,6 +405,7 @@ func (m *Monitor) router() *Router {
if !ok {
return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d")
}
src := m.source()
end := now()
start := end - rg.seconds
window := promRateWindow
@@ -310,18 +422,21 @@ func (m *Monitor) router() *Router {
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) }()
go func() {
defer wg.Done()
got[i], errs[i] = m.rangeQuery(src, 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)
usedDown, errs[n] = m.instantQuery(src, `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)
usedUp, errs[n+1] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
}()
if window != promRateWindow {
for j, k := range rated {
@@ -332,7 +447,7 @@ func (m *Monitor) router() *Router {
wg.Add(1)
go func() {
defer wg.Done()
v, err := m.instantQuery(pq.q, end)
v, err := m.instantQuery(src, pq.q, end)
errs[n+2+j] = err
mu.Lock()
nowVals[k] = v
@@ -344,6 +459,7 @@ func (m *Monitor) router() *Router {
wg.Wait()
for _, err := range errs {
if err != nil {
m.forgetSource()
return c.Error(502, err.Error())
}
}
+143
View File
@@ -0,0 +1,143 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/maeneko/forgetting/internal/agent"
)
func freePort(t *testing.T) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// Панель без VPN, Prometheus — на ноде #0 (сервер, с которого панель переехала): вкладка
// читает его через агента, а сам он скрейпит метрики панели у агента, тот — у core.
func TestPrometheusOnNodeZero(t *testing.T) {
if _, err := exec.LookPath("openssl"); err != nil {
t.Skip("нет openssl для сертификата хаба")
}
dir := t.TempDir()
hubPort := freePort(t)
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
a := testApp(t, dir, "off")
t.Setenv("NODE_PORT", strconv.Itoa(hubPort))
var err error
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
if a.metrics, err = newMetrics(a); err != nil {
t.Fatal(err)
}
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
der, _ := x509.MarshalPKCS8PrivateKey(priv)
keyFile := filepath.Join(dir, "node.key")
os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
if _, err := a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'VPN', ?, 1)", b64url.EncodeToString(pub)); err != nil {
t.Fatal(err)
}
ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/metrics":
io.WriteString(w, `{"up":true,"peers":{"total":2,"online":1}}`)
case "/health":
io.WriteString(w, `{"status":"ok","server":"VPN"}`)
default:
w.WriteHeader(404)
}
}))
defer ctrl.Close()
prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/-/ready":
io.WriteString(w, "Prometheus Server is Ready.\n")
case "/api/v1/query_range":
io.WriteString(w, `{"status":"success","data":{"resultType":"matrix","result":[{"metric":{"server":"VPN","server_id":"0"},"values":[[100,"3"],[115,"4"]]}]}}`)
default:
w.WriteHeader(400)
}
}))
defer prom.Close()
a.nodes.Start()
metricsPort := freePort(t)
hashFile := filepath.Join(dir, "monitor_token.sha256")
sum := sha256.Sum256([]byte("awgm_node"))
os.WriteFile(hashFile, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
ag, err := agent.New(agent.Config{
CoreHost: "127.0.0.1", CorePort: hubPort, CorePin: b64url.EncodeToString(a.nodes.pin), NodeID: 0,
Ctrl: ctrl.URL, NodeKeyFile: keyFile, InternalKey: filepath.Join(dir, "none"),
PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile,
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
go ag.Run(ctx)
for !a.nodes.IsOnline(0) {
if ctx.Err() != nil {
t.Fatal("нода #0 не подключилась")
}
time.Sleep(20 * time.Millisecond)
}
m := newMonitor(a)
m.promURL = "http://127.0.0.1:1" // своего Prometheus у панели нет
src := m.source()
if src == nil || src.local || src.node != 0 {
t.Fatalf("Prometheus не найден на ноде #0: %+v", src)
}
s, err := m.rangeQuery(src, "awg_peers_online", 100, 115, 15)
if err != nil || len(s.ids) != 1 || s.m[0].name != "VPN" || len(s.m[0].points) != 2 || s.m[0].points[1][1] != 4 {
t.Fatalf("query_range через агента: %+v %v", s, err)
}
if h := m.host(m.target(src, nil)); h == nil {
t.Error("вкладка не знает, на каком сервере Prometheus")
}
req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(metricsPort)+"/metrics", nil)
req.Header.Set("Authorization", "Bearer awgm_node")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(string(body), `awg_peers_online{server="VPN",server_id="0"} 1`) {
t.Errorf("скрейп через агента: %d\n%s", resp.StatusCode, body)
}
// Второй скрейп сразу же — отказ: каждый опрашивает все серверы.
resp, err = http.DefaultClient.Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode != 429 {
t.Errorf("частый скрейп: %d", resp.StatusCode)
}
}
}
+79 -13
View File
@@ -6,7 +6,9 @@ package ui
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
//
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает
// как чистая панель. Протокол и формат join-строки — docs/node-protocol.md.
// как чистая панель. Номер 0 тогда может занять нода — сервер, с которого панель переехала
// (`awg-control migrate`): так у его пиров, ключей API и устройств не меняется id сервера.
// Протокол и формат join-строки — docs/node-protocol.md.
import (
"context"
@@ -41,6 +43,8 @@ const (
rpcTimeout = 15 * time.Second
rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго
hubMaxFrame = 4 * 1024 * 1024
// Метрики для Prometheus на ноде: каждый ответ опрашивает все серверы — не чаще этого.
metricsEvery = 5 * time.Second
failWindow = 15 * time.Minute
failMaxHits = 20
)
@@ -108,6 +112,9 @@ type nodeConn struct {
alive bool
pending map[int64]*pending
closed bool
// Запрос метрик от ноды (Prometheus на ней): идёт ли сейчас и когда начат последний.
metricsBusy bool
metricsAt time.Time
}
type deleteVeto struct {
@@ -201,6 +208,18 @@ func newNodes(a *App) (*Nodes, error) {
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
}
@@ -294,15 +313,15 @@ func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp {
// 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 {
if !n.local {
return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
}
}
return n.a.ctrlLocal
}
if n.row(int64(id)) == nil {
return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Сервер не найден")}, nil
}
@@ -311,16 +330,16 @@ func (n *Nodes) CtrlFor(id int) Ctrl {
}
func (n *Nodes) Exists(id int) bool {
if id == 0 {
return n.local
if id == 0 && n.local {
return true
}
return n.row(int64(id)) != nil
}
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
func (n *Nodes) IsOnline(id int) bool {
if id == 0 {
return n.local
if id == 0 && n.local {
return true
}
n.mu.Lock()
defer n.mu.Unlock()
@@ -385,7 +404,7 @@ func (n *Nodes) DefaultID() int {
func (n *Nodes) refreshHealth(id int64) {
var r *CtrlResp
if id == 0 {
if id == 0 && n.local {
r, _ = n.a.ctrlLocal("GET", "/health", nil)
} else {
r = n.rpc(id, "GET", "/health", nil)
@@ -532,7 +551,11 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
n.dropConn(row.ID, old)
closeWS(old.ws, 4000, "replaced")
}
ws.Write(context.Background(), websocket.MessageText, []byte(`{"t":"ready"}`))
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 = " (первое подключение)"
@@ -561,6 +584,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
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") == "metrics" && isInt(m.Get2("id")) {
go n.nodeMetrics(conn, m.Get2("id"))
continue
}
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
rid := int64(m.Get2("id").(float64))
conn.mu.Lock()
@@ -584,6 +617,29 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
}
}
// nodeMetrics — Prometheus на ноде скрейпит метрики панели через её агента: та же экспозиция,
// что /metrics ядра, со всеми серверами (docs/node-protocol.md, «Мониторинг на ноде»).
func (n *Nodes) nodeMetrics(c *nodeConn, id any) {
c.mu.Lock()
t := time.Now()
busy := c.metricsBusy || t.Sub(c.metricsAt) < metricsEvery
if !busy {
c.metricsBusy, c.metricsAt = true, t
}
c.mu.Unlock()
res := jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: id}}
if busy {
res = append(res, jsjson.KV{Key: "status", Val: 429}, jsjson.KV{Key: "text", Val: "слишком часто\n"})
} else {
text := n.a.metrics.render()
c.mu.Lock()
c.metricsBusy = false
c.mu.Unlock()
res = append(res, jsjson.KV{Key: "status", Val: 200}, jsjson.KV{Key: "text", Val: text})
}
c.ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(res))
}
// Start поднимает хаб (если настроен) и периодические ping/health.
func (n *Nodes) Start() {
if n.local {
@@ -598,7 +654,7 @@ func (n *Nodes) Start() {
srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second,
ErrorLog: quietLog()}
go func() {
ln, err := net.Listen("tcp", srv.Addr)
ln, err := listen(n.port)
if err != nil {
warnf("nodes: хаб не запущен: %v", err)
return
@@ -656,6 +712,16 @@ func (n *Nodes) Start() {
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)}})
@@ -732,7 +798,7 @@ func (s serverInfo) json() jsjson.Object {
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
f := jsToNumber(c.Params["id"])
return int64(f), f == float64(int64(f)) && f >= 1
return int64(f), f == float64(int64(f)) && (f >= 1 || f == 0 && !n.local)
}
func (n *Nodes) router() *Router {
+149
View File
@@ -0,0 +1,149 @@
package ui
// PROXY protocol v1 от ретранслятора (`awg-control relay`): после переноса панели старый
// сервер пересылает на неё подписку, хаб нод и панель. Без заголовка все клиенты пришли бы с
// его IP и делили бы на всех лимиты по адресу (неудачные запросы, регистрации, вход). Заголовку
// верим только от адресов из RELAY_FROM; от остальных он — просто байты соединения.
import (
"bufio"
"net"
"os"
"strconv"
"strings"
"time"
)
const proxyHeaderTimeout = 5 * time.Second
// relayFrom — RELAY_FROM: IP ретрансляторов через запятую.
func relayFrom() map[string]bool {
out := map[string]bool{}
for _, s := range strings.Split(os.Getenv("RELAY_FROM"), ",") {
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
out[ip.String()] = true
}
}
return out
}
// listen — net.Listen("tcp", :port), а при заданном RELAY_FROM — с разбором PROXY v1.
func listen(port int) (net.Listener, error) {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(port))
if err != nil {
return nil, err
}
if trusted := relayFrom(); len(trusted) > 0 {
return newProxyListener(ln, trusted), nil
}
return ln, nil
}
type accepted struct {
c net.Conn
err error
}
// proxyListener читает заголовок в своей горутине на соединение: медленный ретранслятор не
// держит Accept для остальных.
type proxyListener struct {
net.Listener
trusted map[string]bool
ch chan accepted
}
func newProxyListener(ln net.Listener, trusted map[string]bool) *proxyListener {
l := &proxyListener{Listener: ln, trusted: trusted, ch: make(chan accepted)}
go l.loop()
return l
}
func (l *proxyListener) loop() {
for {
c, err := l.Listener.Accept()
if err != nil {
l.ch <- accepted{nil, err}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return
}
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
if ip := net.ParseIP(host); ip == nil || !l.trusted[ip.String()] {
l.ch <- accepted{c, nil}
continue
}
go func() {
if pc := readProxyHeader(c); pc != nil {
l.ch <- accepted{pc, nil}
}
}()
}
}
func (l *proxyListener) Accept() (net.Conn, error) {
a := <-l.ch
return a.c, a.err
}
type proxyConn struct {
net.Conn
r *bufio.Reader
remote net.Addr
}
func (c *proxyConn) Read(b []byte) (int, error) { return c.r.Read(b) }
func (c *proxyConn) RemoteAddr() net.Addr { return c.remote }
// readProxyHeader — соединение без заголовка остаётся как есть (с ретранслятора может прийти и
// обычный клиент); битый заголовок — закрываем. nil — соединение закрыто.
func readProxyHeader(c net.Conn) net.Conn {
c.SetReadDeadline(time.Now().Add(proxyHeaderTimeout))
defer c.SetReadDeadline(time.Time{})
r := bufio.NewReaderSize(c, 256)
pc := &proxyConn{Conn: c, r: r, remote: c.RemoteAddr()}
if sig, err := r.Peek(6); err != nil || string(sig) != "PROXY " {
if err != nil && len(sig) == 0 {
c.Close()
return nil
}
return pc
}
// Строка v1 — не длиннее 107 байт вместе с CRLF.
line, err := r.ReadSlice('\n')
if err != nil || len(line) > 107 || !strings.HasSuffix(string(line), "\r\n") {
c.Close()
return nil
}
if addr, ok := parseProxyV1(strings.TrimSuffix(string(line), "\r\n")); ok {
if addr != nil {
pc.remote = addr
}
return pc
}
c.Close()
return nil
}
// parseProxyV1 — «PROXY TCP4|TCP6 src dst sport dport» или «PROXY UNKNOWN …» (addr == nil).
func parseProxyV1(line string) (*net.TCPAddr, bool) {
f := strings.Split(line, " ")
if len(f) >= 2 && f[0] == "PROXY" && f[1] == "UNKNOWN" {
return nil, true
}
if len(f) != 6 || f[0] != "PROXY" || f[1] != "TCP4" && f[1] != "TCP6" {
return nil, false
}
ip := net.ParseIP(f[2])
if ip == nil || net.ParseIP(f[3]) == nil || (ip.To4() != nil) != (f[1] == "TCP4") {
return nil, false
}
port, err := strconv.Atoi(f[4])
if err != nil || port < 0 || port > 65535 {
return nil, false
}
if p, err := strconv.Atoi(f[5]); err != nil || p < 0 || p > 65535 {
return nil, false
}
return &net.TCPAddr{IP: ip, Port: port}, true
}
+102
View File
@@ -0,0 +1,102 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
// После переноса старый сервер выключают, когда новый адрес знают все: хаб сообщает его
// нодам в ready и запоминает подтверждение (core_ok), клиенты получают его в endpoints.
// Вкладка «Перенос» показывает, сколько осталось.
func TestRetireReadiness(t *testing.T) {
t.Setenv("SUB_HOST", "198.51.100.20")
p := newMovePanel(t, "off", "40500")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
for _, q := range []string{
"INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'Старый', 'x', 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', '" + b64url.EncodeToString(pub) + "', 1)",
"INSERT INTO nodes (id, name, created_at) VALUES (4, 'Ещё не подключена', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (2, 'u2', 'Не открыта', x'02', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (3, 'u3', 'Отозвана', x'03', 1, 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (5, 1, 'a', 'W5', 'A5', 1, 2000)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (6, 1, 'b', 'W6', 'A6', 1, 500)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (7, 1, 'c', 'W7', 'A7', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (8, 3, 'd', 'W8', 'A8', 1, 2000)",
} {
if _, err := p.a.db.Exec(q); err != nil {
t.Fatal(q, err)
}
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
t.Fatal(err)
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o
}
write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) }
nonce := read().Get2("nonce").(string)
write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}})
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
t.Fatalf("ready: %s", got)
}
retire := func() jsjson.Object {
_, o := p.call(t, p.a.migration.status, nil)
r, _ := o.Get2("retire").(jsjson.Object)
return r
}
if retire() != nil {
t.Error("готовность показана панели, которая ничего не принимала")
}
t.Setenv("MOVED_IN_AT", "1000")
if n := string(jsjson.MustMarshal(retire().Get2("nodes"))); n != `[{"id":3,"name":"Frankfurt","online":true,"ok":false}]` {
t.Errorf("до core_ok: %s", n)
}
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "203.0.113.66:1"}}) // не наш адрес — мимо
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "198.51.100.20:40500"}})
var r jsjson.Object
for range 50 {
if r = retire(); strings.Contains(string(jsjson.MustMarshal(r.Get2("nodes"))), `"ok":true`) {
break
}
time.Sleep(20 * time.Millisecond)
}
want := `{"since":1000,"home":"198.51.100.20:40500","old":{"exists":true,"online":false},` +
`"devices":{"total":3,"fresh":1},"unused_links":1,"nodes":[{"id":3,"name":"Frankfurt","online":true,"ok":true}]}`
if got := string(jsjson.MustMarshal(r)); got != want {
t.Errorf("готовность:\n got %s\nwant %s", got, want)
}
os.WriteFile(p.env, []byte("UI_PASS=x\nMOVED_IN_AT=1000\n"), 0o600)
w := httptest.NewRecorder()
p.a.migration.router().ServeHTTP(w, httptest.NewRequest("DELETE", "/retire", nil))
if raw, _ := os.ReadFile(p.env); strings.Contains(string(raw), "MOVED_IN_AT") || retire() != nil {
t.Errorf("отметка не убрана: %q", raw)
}
}
+11 -3
View File
@@ -11,10 +11,10 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
@@ -113,6 +113,7 @@ type App struct {
sub *Sub
metrics *Metrics
monitor *Monitor
migration *Migration
}
// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса
@@ -185,6 +186,7 @@ func New() (*App, error) {
return nil, err
}
a.monitor = newMonitor(a)
a.migration = newMigration(a)
a.nodes.BeforeDelete(a.apiKeysBeforeDelete)
a.nodes.AfterDelete(a.apiKeysAfterDelete)
return a, nil
@@ -876,6 +878,10 @@ func (a *App) routes() *Router {
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
app.Use("/ui/monitor", auth)
app.Mount("/ui/monitor", a.monitor.router())
// Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса.
app.Post("/migrate/v1/import", a.migration.importHandler)
app.Use("/ui/migrate", auth)
app.Mount("/ui/migrate", a.migration.router())
for _, p := range []string{"/api", "/health", "/awg"} {
app.Use(p, auth, a.proxy)
@@ -903,7 +909,9 @@ func (a *App) static(c *Ctx) bool {
if st, err := os.Stat(f); err == nil && !hidden {
if st.IsDir() {
if !strings.HasSuffix(c.R.URL.Path, "/") {
http.Redirect(c.W, c.R, c.R.URL.Path+"/", http.StatusMovedPermanently)
// Относительный адрес: за прокси в подпапке абсолютный увёл бы из неё.
c.W.Header().Set("Location", path.Base(c.R.URL.Path)+"/")
c.W.WriteHeader(http.StatusMovedPermanently)
return false
}
f = filepath.Join(f, "index.html")
@@ -937,7 +945,7 @@ func serveFile(w http.ResponseWriter, r *http.Request, f string) {
// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку).
func (a *App) Run() error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port))
ln, err := listen(a.port)
if err != nil {
return err
}
+18 -3
View File
@@ -29,13 +29,13 @@ import (
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
@@ -186,11 +186,16 @@ type Sub struct {
locksMu sync.Mutex
locks map[int]*sync.Mutex // один проход на сервер за раз
peersMu sync.Mutex
peerCalls map[int]*peersCall // последний GET /api/peers по серверу, см. peersOf
sigMu sync.Mutex
seenSigs map[string]time.Time // подпись → когда забыть
registerLimit, failLimit *limiter
frozen atomic.Bool // идёт перенос панели (migrate.go): /sub/v1 отвечает «недоступна»
auditMu sync.Mutex
lastAudit *auditReport
auditSettings auditSettings
@@ -205,6 +210,7 @@ func newSub(a *App) (*Sub, error) {
host: strings.TrimSpace(os.Getenv("SUB_HOST")),
auditOn: strings.ToLower(envOr("AUDIT", "on")) != "off",
locks: map[int]*sync.Mutex{},
peerCalls: map[int]*peersCall{},
seenSigs: map[string]time.Time{},
}
limited := func(what string) func(string) {
@@ -776,7 +782,8 @@ func (s *Sub) subHost(c *Ctx) string {
if s.host != "" {
return s.host
}
if s.a.nodes.Exists(0) {
// Только свой сервер: у ноды №0 (переехавшей панели) endpoint — другая машина.
if s.a.nodes.local {
if p := s.storedProfile(0); p != nil {
if ep, ok := p.Get2("endpoint").(string); ok {
return hostOfEndpoint(ep)
@@ -1196,6 +1203,14 @@ func (s *Sub) publicRouter() *Router {
s.send(w, r, 400, subErr("bad_request"))
}
rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) }
// Перенос панели: изменения сейчас остались бы только в уезжающей базе. 502 unavailable
// клиент понимает как «подписка недоступна» — туннель не трогает и повторит позже.
rt.Use("/", func(c *Ctx) bool {
if s.frozen.Load() {
return s.sendC(c, 502, subErr("unavailable"))
}
return true
})
rt.Post("/sub/v1/register", s.wrapSub(s.register))
rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
@@ -1244,7 +1259,7 @@ func (s *Sub) Start() {
return
}
srv := &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: s.publicRouter(), ReadHeaderTimeout: 15 * time.Second, ErrorLog: quietLog()}
ln, err := net.Listen("tcp", srv.Addr)
ln, err := listen(s.port)
if err != nil {
warnf("sub: листенер не запущен: %v", err)
return
+295 -46
View File
@@ -14,6 +14,7 @@ import (
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -104,12 +105,17 @@ func (s *Sub) visible(masterID int64, scope []int) bool {
return true
}
// masterFor — живой (не удаляемый) ключ в пределах области; чужой — как несуществующий.
// masterFor — живой (не удаляемый) ключ в пределах области. Ключ, которого нет или который
// удалён, — 404; ключ, у которого есть сервер вне области API-ключа, — 403: клиент не должен
// принимать его за удалённый и выдавать вместо него второй (так у пользователя появлялся дубль).
func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) {
m := s.masterByID(s.a.db, id)
if m == nil || !s.visible(m.ID, scope) {
if m == nil {
return nil, fail(404, "Не найден")
}
if !s.visible(m.ID, scope) {
return nil, fail(403, "Ключ вне области API-ключа")
}
return m, nil
}
@@ -204,6 +210,12 @@ func (s *Sub) createMaster(body any, scope []int) (jsjson.Object, error) {
list = append(list, float64(id))
}
src = list
} else if def := s.defaultServers(); def != nil {
list := []any{}
for _, id := range def {
list = append(list, float64(id))
}
src = list
} else {
src = []any{float64(s.a.nodes.DefaultID())}
}
@@ -275,27 +287,7 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
removed = append(removed, x)
}
}
devices := s.devicesOf(s.a.db, m.ID)
if err := s.a.tx(func(tx *sql.Tx) error {
for _, sid := range removed {
tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid)
for _, d := range devices {
for _, l := range s.linksOf(tx, d.ID) {
if l.ServerID == sid && l.PeerPub.Valid {
tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String)
}
}
tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid)
}
}
for _, sid := range added {
tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid)
for _, d := range devices {
tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid)
}
}
return nil
}); err != nil {
if err := s.a.tx(func(tx *sql.Tx) error { return s.moveServersTx(tx, m.ID, added, removed) }); err != nil {
return nil, err
}
affected = append(append(affected, added...), removed...)
@@ -323,6 +315,215 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
{Key: "servers", Val: intsJSON(ids)}, {Key: "unbound", Val: unbound}}, nil
}
// moveServersTx — ключ получает серверы added и теряет removed: пиры его устройств на снятых
// серверах — в надгробия (их снимет reconcile), на добавленных — заявки без пира (заведёт он же).
func (s *Sub) moveServersTx(tx *sql.Tx, masterID int64, added, removed []int) error {
devices := s.devicesOf(tx, masterID)
for _, sid := range removed {
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", masterID, sid); err != nil {
return err
}
for _, d := range devices {
for _, l := range s.linksOf(tx, d.ID) {
if l.ServerID == sid && l.PeerPub.Valid {
if _, err := tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String); err != nil {
return err
}
}
}
if _, err := tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid); err != nil {
return err
}
}
}
for _, sid := range added {
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", masterID, sid); err != nil {
return err
}
for _, d := range devices {
if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid); err != nil {
return err
}
}
}
return nil
}
// ── Общая настройка серверов ────────────────────────────────────────────────
// defaultServers — серверы новых ключей панели (без явного набора), из настройки «Серверы для
// всех»; nil — не задано или ни одного из них уже нет.
func (s *Sub) defaultServers() []int {
var out []int
for _, v := range arr(s.metaGet("default_servers")) {
if isInt(v) && s.a.nodes.Exists(int(v.(float64))) {
out = append(out, int(v.(float64)))
}
}
return out
}
// bulkServers — один набор правок для всех живых ключей: add — у всех, remove — ни у кого,
// остальные серверы каждого ключа не трогаются. dry — только посчитать. hidden — ключи API,
// которые потеряют доступ к части ключей: ключ виден API-ключу, только пока все его серверы в
// области (visible), и добавление сервера вне области его прячет.
func (s *Sub) bulkServers(body any) (jsjson.Object, error) {
b, _ := body.(jsjson.Object)
ids := func(k string) ([]int, error) {
v, has := b.Get(k)
if !has || v == nil {
return nil, nil
}
if l, ok := v.([]any); ok && len(l) == 0 {
return nil, nil
}
out, ok := s.a.serverList(v, s.a.nodes.Exists)
if !ok {
return nil, fail(400, "Такого сервера нет")
}
return out, nil
}
add, err := ids("add")
if err != nil {
return nil, err
}
remove, err := ids("remove")
if err != nil {
return nil, err
}
for _, x := range add {
if containsInt(remove, x) {
return nil, fail(400, "Сервер не может быть и у всех, и ни у кого")
}
}
defaults, hasDefaults := b.Get("defaults")
var defIDs []int
if hasDefaults && defaults != nil {
if defIDs, err = s.parseServers(defaults, nil); err != nil {
return nil, err
}
}
type change struct {
id int64
added, removed []int
before, after []int
}
var changes []change
var masters []int64
rows, err := s.a.db.Query("SELECT id FROM master_keys WHERE revoked_at IS NULL ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
masters = append(masters, id)
}
}
rows.Close()
empty := 0
for _, id := range masters {
cur := s.serverIDsOf(s.a.db, id)
c := change{id: id, before: cur}
for _, x := range cur {
if containsInt(remove, x) {
c.removed = append(c.removed, x)
} else {
c.after = append(c.after, x)
}
}
for _, x := range add {
if !containsInt(cur, x) {
c.added = append(c.added, x)
c.after = append(c.after, x)
}
}
if len(c.after) == 0 {
empty++
}
if len(c.added)+len(c.removed) > 0 {
changes = append(changes, c)
}
}
if empty > 0 {
return nil, fail(409, fmt.Sprintf("У %d ключей не останется ни одного сервера — оставьте им хотя бы один", empty))
}
// Области ключей API: id → серверы.
type apiKey struct {
label string
scope []int
}
keys := map[int64]*apiKey{}
var order []int64
if r, err := s.a.db.Query("SELECT k.id, k.label, ks.server_id FROM api_keys k LEFT JOIN api_key_servers ks ON ks.key_id = k.id ORDER BY k.id"); err == nil {
for r.Next() {
var id int64
var label string
var sid sql.NullInt64
if r.Scan(&id, &label, &sid) != nil {
continue
}
if keys[id] == nil {
keys[id] = &apiKey{label: label}
order = append(order, id)
}
if sid.Valid {
keys[id].scope = append(keys[id].scope, int(sid.Int64))
}
}
r.Close()
}
within := func(list, scope []int) bool {
for _, x := range list {
if !containsInt(scope, x) {
return false
}
}
return true
}
hidden := []any{}
for _, id := range order {
k, n := keys[id], 0
for _, c := range changes {
if within(c.before, k.scope) && !within(c.after, k.scope) {
n++
}
}
if n > 0 {
hidden = append(hidden, jsjson.Object{{Key: "id", Val: id}, {Key: "label", Val: k.label}, {Key: "keys", Val: n}})
}
}
out := jsjson.Object{{Key: "changed", Val: len(changes)}, {Key: "total", Val: len(masters)}, {Key: "hidden", Val: hidden}}
if b.Get2("dry_run") == true {
return out, nil
}
var affected []int
if err := s.a.tx(func(tx *sql.Tx) error {
for _, c := range changes {
if err := s.moveServersTx(tx, c.id, c.added, c.removed); err != nil {
return err
}
affected = append(append(affected, c.added...), c.removed...)
}
return nil
}); err != nil {
return nil, err
}
if hasDefaults {
if defIDs == nil {
s.a.db.Exec("DELETE FROM sub_meta WHERE key = 'default_servers'")
} else {
s.metaSet("default_servers", intsJSON(defIDs))
}
}
logf("masterkeys: серверы для всех — добавлены %v, сняты %v, ключей изменено: %d", add, remove, len(changes))
s.reconcileAll(affected)
return out, nil
}
// rotateMaster — новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
func (s *Sub) rotateMaster(m *masterRow) {
secret := make([]byte, 16)
@@ -380,13 +581,61 @@ type devStat struct {
torrent bool
}
// peersTTL — сколько держится ответ GET /api/peers сервера. Вкладка за один проход просит
// устройства каждого ключа, и без этого каждый запрос заново тянул бы пиров со всех серверов.
const peersTTL = 3 * time.Second
type peersCall struct {
done chan struct{} // закрывается, когда ответ получен
at time.Time // нулевое — ответ не годится для повторного использования (ошибка)
peers []any
err error
}
// peersOf — пиры сервера с их статистикой. Параллельные запросы к одному серверу делят один
// вызов; свежий ответ переиспользуется peersTTL, ошибка — нет.
func (s *Sub) peersOf(id int) ([]any, error) {
s.peersMu.Lock()
if c := s.peerCalls[id]; c != nil {
select {
case <-c.done:
if !c.at.IsZero() && time.Since(c.at) < peersTTL {
s.peersMu.Unlock()
return c.peers, c.err
}
default: // уже идёт — дождаться его же
s.peersMu.Unlock()
<-c.done
return c.peers, c.err
}
}
c := &peersCall{done: make(chan struct{})}
s.peerCalls[id] = c
s.peersMu.Unlock()
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
switch {
case err != nil:
c.err = err
case !ok2xx(r.Status):
c.err = fmt.Errorf("awg-ctrl ответил %d", r.Status)
default:
c.peers = arr(field(r.Data, "peers"))
c.at = time.Now()
}
close(c.done)
return c.peers, c.err
}
// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий
// handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ.
func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
// Сервер, который не ответил, не рушит список: его статистики просто нет, а id возвращается
// вторым значением — панель покажет, что данные неполные.
func (s *Sub) masterDevices(m *masterRow) ([]any, []int) {
stats := map[string]*devStat{}
var mu sync.Mutex
var wg sync.WaitGroup
var firstErr error
unreachable := []int{}
for _, id := range s.serverIDsOf(s.a.db, m.ID) {
if !s.a.nodes.IsOnline(id) {
continue
@@ -394,19 +643,14 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
wg.Add(1)
go func() {
defer wg.Done()
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
peers, err := s.peersOf(id)
mu.Lock()
defer mu.Unlock()
if err != nil {
if firstErr == nil {
firstErr = err
}
unreachable = append(unreachable, id)
return
}
if !ok2xx(r.Status) {
return
}
for _, p := range arr(field(r.Data, "peers")) {
for _, p := range peers {
pub, _ := field(p, "pub_key").(string)
st := stats[pub]
if st == nil {
@@ -426,9 +670,7 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
}()
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
sort.Ints(unreachable)
out := []any{}
for _, d := range s.devicesOf(s.a.db, m.ID) {
links := s.linksOf(s.a.db, d.ID)
@@ -459,7 +701,7 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
{Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN}, {Key: "ips", Val: ips},
})
}
return out, nil
return out, unreachable
}
func (s *Sub) deleteDevice(d *deviceRow) jsjson.Object {
@@ -514,8 +756,18 @@ func (s *Sub) masterKeysRouter() *Router {
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) }
rt.Get("/", func(c *Ctx) bool {
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)}})
var defaults any
if d := s.defaultServers(); d != nil {
defaults = intsJSON(d)
}
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)},
{Key: "defaults", Val: defaults}})
})
// «Серверы для всех»: добавить/снять серверы у всех ключей разом и задать набор для новых.
rt.Post("/servers", aw(func(c *Ctx) (int, any, error) {
out, err := s.bulkServers(c.Body)
return 200, out, err
}))
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) })
rt.Post("/audit", aw(func(c *Ctx) (int, any, error) {
@@ -613,8 +865,8 @@ func (s *Sub) masterKeysRouter() *Router {
if err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
list, down := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}, {Key: "unreachable", Val: intsJSON(down)}}, nil
}))
return rt
}
@@ -704,10 +956,7 @@ func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router {
} else if out, err = withLink(c, m, out); err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
if err != nil {
return 0, nil, err
}
list, _ := s.masterDevices(m)
return 200, merge(out, jsjson.Object{{Key: "device_list", Val: list}}), nil
}))
rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) {
@@ -751,8 +1000,8 @@ func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router {
if err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
list, _ := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, nil
}))
rt.Delete("/:id/devices/:device", aw(func(c *Ctx) (int, any, error) {
d, err := device(c)
+62
View File
@@ -0,0 +1,62 @@
package ui
import (
"errors"
"path/filepath"
"testing"
)
// Ключ, который есть, но выходит за область API-ключа, — 403, а не 404: клиент не должен
// принимать его за удалённый и выдавать вместо него второй.
func TestMasterForScope(t *testing.T) {
dir := t.TempDir()
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := &Sub{a: &App{db: db, baseDir: dir}} // baseDir: initSchema кладёт туда panel.id
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatal(err)
}
}
for id, secret := range map[int]string{1: "a", 2: "b", 3: "c"} {
exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (?, ?, 'k', ?, 1)", id, "u"+secret, []byte(secret))
}
exec("INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (2, 0), (2, 1), (3, 0)")
exec("UPDATE master_keys SET revoked_at = 1 WHERE id = 3")
status := func(id int64, scope []int) int {
_, err := s.masterFor(id, scope)
var ae *apiError
if err == nil {
return 200
}
if !errors.As(err, &ae) {
t.Fatalf("masterFor(%d, %v): %v", id, scope, err)
}
return ae.status
}
for _, c := range []struct {
name string
id int64
scope []int
want int
}{
{"панель видит всё", 2, nil, 200},
{"ключ в области", 1, []int{0}, 200},
{"сервер ключа вне области", 2, []int{0}, 403},
{"нет такого ключа", 99, []int{0}, 404},
{"удалённый ключ", 3, []int{0}, 404},
{"удалённый ключ в панели", 3, nil, 404},
} {
if got := status(c.id, c.scope); got != c.want {
t.Errorf("%s: %d, ожидали %d", c.name, got, c.want)
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package ui
// Панель за reverse-proxy (nginx и т. п., в корне домена или в подпапке): соединение приходит от
// прокси, а адрес клиента — в X-Forwarded-For / X-Real-IP. Иначе все клиенты были бы одним IP и
// делили бы лимиты по адресу (неудачные входы, регистрации). Заголовкам верим только от прокси:
// loopback (прокси на этой же машине) всегда, остальные адреса и сети — из TRUST_PROXY. От всех
// прочих заголовки игнорируются — подделать свой адрес клиенту нечем.
import (
"net"
"os"
"strconv"
"strings"
"sync"
)
// trustedProxies — TRUST_PROXY: IP и сети (CIDR) через запятую; loopback доверен без него.
var trustedProxies = sync.OnceValue(func() []*net.IPNet { return parseTrustProxy(os.Getenv("TRUST_PROXY")) })
func parseTrustProxy(v string) []*net.IPNet {
var out []*net.IPNet
for _, s := range strings.Split(v, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if !strings.Contains(s, "/") {
if ip := net.ParseIP(s); ip != nil {
bits := 128
if ip.To4() != nil {
bits = 32
}
s = ip.String() + "/" + strconv.Itoa(bits)
}
}
if _, n, err := net.ParseCIDR(s); err == nil {
out = append(out, n)
} else {
warnf("TRUST_PROXY: «%s» — не IP и не сеть, пропущено", s)
}
}
return out
}
func isTrustedProxy(ip net.IP, nets []*net.IPNet) bool {
if ip.IsLoopback() {
return true
}
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
// clientIP — адрес клиента: от доверенного прокси — из X-Forwarded-For (справа налево, первый
// адрес не из доверенных: левые части клиент мог дописать сам) или X-Real-IP; иначе — сам peer.
func clientIP(peer string, h interface{ Values(string) []string }, nets []*net.IPNet) string {
ip := net.ParseIP(peer)
if ip == nil || !isTrustedProxy(ip, nets) {
return peer
}
var hops []string
for _, v := range h.Values("X-Forwarded-For") {
hops = append(hops, strings.Split(v, ",")...)
}
found := ""
for i := len(hops) - 1; i >= 0; i-- {
hop := net.ParseIP(strings.TrimSpace(hops[i]))
if hop == nil {
break
}
found = hop.String()
if !isTrustedProxy(hop, nets) {
return found
}
}
if found != "" {
return found
}
for _, v := range h.Values("X-Real-IP") {
if r := net.ParseIP(strings.TrimSpace(v)); r != nil {
return r.String()
}
}
return peer
}
+57
View File
@@ -0,0 +1,57 @@
package ui
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// За reverse-proxy адрес клиента берётся из его заголовков, но только когда соединение пришло от
// самого прокси (loopback или TRUST_PROXY): напрямую подставить себе чужой IP нельзя.
func TestClientIP(t *testing.T) {
nets := parseTrustProxy("10.0.0.0/8, 192.0.2.7, мусор")
for _, c := range []struct {
name, peer string
xff []string
real, want string
}{
{"напрямую, заголовки игнорируются", "203.0.113.5", []string{"1.2.3.4"}, "1.2.3.4", "203.0.113.5"},
{"nginx на этой машине", "127.0.0.1", []string{"203.0.113.5"}, "", "203.0.113.5"},
{"IPv6 loopback", "::1", []string{"2001:db8::5"}, "", "2001:db8::5"},
{"клиент дописал себе адрес слева", "127.0.0.1", []string{"6.6.6.6, 203.0.113.5"}, "", "203.0.113.5"},
{"цепочка доверенных прокси", "10.1.1.1", []string{"203.0.113.5, 10.2.2.2", "192.0.2.7"}, "", "203.0.113.5"},
{"только X-Real-IP", "127.0.0.1", nil, "203.0.113.9", "203.0.113.9"},
{"прокси без заголовков", "127.0.0.1", nil, "", "127.0.0.1"},
{"мусор в X-Forwarded-For", "127.0.0.1", []string{"unknown"}, "203.0.113.9", "203.0.113.9"},
{"чужой прокси не доверен", "198.51.100.1", []string{"203.0.113.5"}, "", "198.51.100.1"},
} {
h := http.Header{}
for _, v := range c.xff {
h.Add("X-Forwarded-For", v)
}
if c.real != "" {
h.Set("X-Real-IP", c.real)
}
if got := clientIP(c.peer, h, nets); got != c.want {
t.Errorf("%s: %s, ожидали %s", c.name, got, c.want)
}
}
if len(nets) != 2 {
t.Errorf("TRUST_PROXY разобран в %d сетей", len(nets))
}
}
// Каталог без слэша — относительный редирект: за прокси в подпапке абсолютный увёл бы из неё.
func TestStaticRelativeRedirect(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "dist", "assets"), 0o755)
os.WriteFile(filepath.Join(dir, "dist", "assets", "index.html"), []byte("x"), 0o644)
a := &App{baseDir: dir}
w := httptest.NewRecorder()
a.static(&Ctx{W: w, R: httptest.NewRequest("GET", "/assets", nil)})
if w.Code != 301 || w.Header().Get("Location") != "assets/" {
t.Errorf("%d %q", w.Code, w.Header().Get("Location"))
}
}
+2 -1
View File
@@ -212,12 +212,13 @@ func (c *Ctx) Obj() jsjson.Object {
}
// remoteIP — req.socket.remoteAddress.
// remoteIP — адрес клиента; за доверенным reverse-proxy — из его заголовков (trustproxy.go).
func (c *Ctx) remoteIP() string {
host, _, err := net.SplitHostPort(c.R.RemoteAddr)
if err != nil {
return "unknown"
}
return host
return clientIP(host, c.R.Header, trustedProxies())
}
// hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках.