2 Commits
Author SHA1 Message Date
maeneko 45c96aa0b6 Bump to Beta 0.4.5
release / release (push) Successful in 18m26s
ci / check (push) Successful in 24m11s
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
38 changed files with 3610 additions and 113 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting BRAND=Forgetting
CHANNEL=Beta CHANNEL=Beta
VERSION=0.4.4 VERSION=0.4.5
+12 -4
View File
@@ -151,7 +151,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ | | Метод | Ответ |
|---|---| |---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина | | `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.5" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
@@ -198,8 +198,9 @@ base64url-декодировать, отбросить первые 4 байта
| Метод | Что делает | | Метод | Что делает |
|---|---| |---|---|
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`; `:id` в путях — id или `uuid` | | `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` | | `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? }` | | `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` | | `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком | | `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
@@ -211,7 +212,14 @@ base64url-декодировать, отбросить первые 4 байта
## Управление ## Управление
```bash ```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 (автозапуск на загрузке): Через systemd (автозапуск на загрузке):
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-agent", "name": "awg-agent",
"version": "0.4.4", "version": "0.4.5",
"private": true, "private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl", "description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.4.4", "version": "0.4.5",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+63 -3
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[] } 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; 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 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 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 } : {}) }); 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 — ещё не подключалась). // Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
@@ -121,6 +123,50 @@ app.get("/ui/monitor", (_q, r) => {
for (const action of ["install", "remove"] as const) { for (const action of ["install", "remove"] as const) {
app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); }); app.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) => { 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 R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "7d": [604800, 1800], "30d": [2592000, 7200] };
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"]; const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
@@ -248,11 +294,25 @@ app.post("/ui/masterkeys/audit/clean", (_q, r) => {
mockGhost = false; r.json(mockAudit()); mockGhost = false; r.json(mockAudit());
}); });
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; 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) => { app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3; const limit = q.body.device_limit ?? 3;
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" }); 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)); masters.push(m); r.status(201).json(mkOut(m));
}); });
app.patch("/ui/masterkeys/:id", (q, r) => { app.patch("/ui/masterkeys/:id", (q, r) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.4.4", "version": "0.4.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+7
View File
@@ -102,9 +102,16 @@ export const IcoPulse = () => (
</svg> </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> = { export const ICONS: Record<string, () => JSX.Element> = {
users: IcoUsers, users: IcoUsers,
key: IcoKey, key: IcoKey,
shield: IcoShield, shield: IcoShield,
pulse: IcoPulse, pulse: IcoPulse,
move: IcoMove,
}; };
@@ -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>
);
}
+17 -5
View File
@@ -1,13 +1,14 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'; import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared'; 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 { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker'; import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip'; import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip'; import TorrentChip from '../../components/TorrentChip';
import BulkServersModal from './BulkServersModal';
import './masterkeys.css'; import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка // Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
@@ -66,6 +67,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
const creatingRef = useRef(false); const creatingRef = useRef(false);
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null); 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 load = useCallback(async () => {
const seq = ++loadSeq.current; const seq = ++loadSeq.current;
@@ -73,7 +76,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const d = await apiFetch('GET', '/ui/masterkeys', token); const d = await apiFetch('GET', '/ui/masterkeys', token);
const list: MasterKey[] = d.keys ?? []; const list: MasterKey[] = d.keys ?? [];
if (seq !== loadSeq.current) return; if (seq !== loadSeq.current) return;
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setDefaults(d.defaults ?? null);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных. // Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
const live = list.filter(k => !k.deleting); const live = list.filter(k => !k.deleting);
@@ -106,15 +109,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
if (!label.trim() || creatingRef.current) return; if (!label.trim() || creatingRef.current) return;
creatingRef.current = true; setCreating(true); creatingRef.current = true; setCreating(true);
try { await act(async () => { try { await act(async () => {
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа. // Новый ключ — с серверами из «Серверы для всех» (их подставит панель), а не задано — со всеми,
// которые уже подключались; набор потом правится в настройках ключа.
const ready = nodes.filter(n => !n.pending).map(n => n.id); const ready = nodes.filter(n => !n.pending).map(n => n.id);
await apiFetch('POST', '/ui/masterkeys', token, { 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(''); setLabel('');
}, 'Мастер-ключ создан'); } }, 'Мастер-ключ создан'); }
finally { creatingRef.current = false; setCreating(false); } finally { creatingRef.current = false; setCreating(false); }
}, [label, limit, nodes, token, act]); }, [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]); const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
@@ -403,6 +408,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} /> aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns"> <div className="toolbar-btns">
<button className="btn btn--primary" onClick={create} disabled={creating}><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>
</div> </div>
@@ -518,6 +525,11 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</ul> </ul>
)} )}
{bulk && (
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
)}
{modal && ( {modal && (
<div className="qr-backdrop" onClick={() => setModal(null)}> <div className="qr-backdrop" onClick={() => setModal(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}> <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; } .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; }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.4.4", "version": "0.4.5",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "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 package main
import ( import (
@@ -11,6 +11,7 @@ import (
"github.com/maeneko/forgetting/internal/agent" "github.com/maeneko/forgetting/internal/agent"
"github.com/maeneko/forgetting/internal/cli" "github.com/maeneko/forgetting/internal/cli"
"github.com/maeneko/forgetting/internal/ctrl" "github.com/maeneko/forgetting/internal/ctrl"
"github.com/maeneko/forgetting/internal/relay"
"github.com/maeneko/forgetting/internal/ui" "github.com/maeneko/forgetting/internal/ui"
) )
@@ -60,6 +61,13 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
a.Run(ctx) 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: default:
cli.Main(os.Args[1:]) 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 из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места. # При бампе версии правь оба места.
VERSION="0.4.4" VERSION="0.4.5"
CHANNEL="${CHANNEL:-Beta}" CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник # Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
@@ -702,8 +702,9 @@ parse_join() {
CORE_PIN=$(sed -nE 's/.*"pin":"([A-Za-z0-9_-]{43})".*/\1/p' <<< "$json") 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") 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") JOIN_SECRET=$(sed -nE 's/.*"s":"([A-Za-z0-9_-]{22})".*/\1/p' <<< "$json")
[[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" ]] \ # NODE_ID 0 — сервер, с которого переехала панель (awg-ctrl migrate import).
&& (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID > 0 )) [[ -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 в строке. # SHA-256 от SPKI сертификата панели, base64url без «=» — в том же виде, что pin в строке.
+89 -6
View File
@@ -17,6 +17,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -29,6 +30,7 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/iauth" "github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson" "github.com/maeneko/forgetting/internal/jsjson"
) )
@@ -47,6 +49,7 @@ const (
type Config struct { type Config struct {
CoreHost string CoreHost string
CorePort int CorePort int
CoreEndpoint string // «host:port», который core сообщил сам (после переезда панели); пробуется первым
CorePin string // SHA-256 SPKI сертификата core, base64url CorePin string // SHA-256 SPKI сертификата core, base64url
NodeID int NodeID int
Ctrl string // http://127.0.0.1:PORT Ctrl string // http://127.0.0.1:PORT
@@ -77,6 +80,8 @@ func envFromFile(file, key string) string {
func ConfigFromEnv() (Config, error) { func ConfigFromEnv() (Config, error) {
cwd, _ := os.Getwd() cwd, _ := os.Getwd()
atoi := func(k string) int { n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(k))); return n } 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 { or := func(k, def string) string {
if v, ok := os.LookupEnv(k); ok { if v, ok := os.LookupEnv(k); ok {
return v return v
@@ -87,7 +92,8 @@ func ConfigFromEnv() (Config, error) {
CoreHost: os.Getenv("CORE_HOST"), CoreHost: os.Getenv("CORE_HOST"),
CorePort: atoi("CORE_PORT"), CorePort: atoi("CORE_PORT"),
CorePin: os.Getenv("CORE_PIN"), 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"), Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"),
NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")), NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")),
InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"), InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"),
@@ -98,7 +104,7 @@ func ConfigFromEnv() (Config, error) {
if c.Version == "" { if c.Version == "" {
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "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, errors.New("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`")
} }
return c, nil return c, nil
@@ -112,6 +118,8 @@ type Agent struct {
mu sync.Mutex mu sync.Mutex
joinSecret string joinSecret string
addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT)
cur int
http *http.Client http *http.Client
} }
@@ -152,6 +160,7 @@ func New(c Config) (*Agent, error) {
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен. // awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout}, http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout},
} }
a.setAddrs(c.CoreEndpoint)
if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil { if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil {
a.internalKey = ik a.internalKey = ik
} else { } else {
@@ -306,6 +315,71 @@ func (a *Agent) forgetJoinSecret() {
os.WriteFile(a.c.CLIEnvFile, []byte(strings.Join(kept, "\n")), 0o600) 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 { func (a *Agent) secret() string {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
@@ -337,7 +411,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout} tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
dctx, dcancel := context.WithTimeout(ctx, 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}, HTTPClient: &http.Client{Transport: tr},
CompressionMode: websocket.CompressionDisabled, CompressionMode: websocket.CompressionDisabled,
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true }, OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
@@ -405,7 +479,12 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
a.forgetJoinSecret() a.forgetJoinSecret()
a.c.Logf("agent: нода привязана к core") 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 == "req" && ready: case t == "req" && ready:
id, ok := m.Get2("id").(float64) id, ok := m.Get2("id").(float64)
if !ok || id != float64(int64(id)) { if !ok || id != float64(int64(id)) {
@@ -429,10 +508,14 @@ func hostPort(host string, port int) string {
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута. // Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
func (a *Agent) Run(ctx context.Context) error { 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())
backoff := time.Second backoff := time.Second
for { 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 wait := backoff
if authFailed { if authFailed {
wait = maxBackoff wait = maxBackoff
+83
View File
@@ -268,3 +268,86 @@ func TestNodeKeyPersists(t *testing.T) {
t.Fatalf("node.key mode %v", st.Mode().Perm()) 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")
}
}
+50 -19
View File
@@ -17,6 +17,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson" "github.com/maeneko/forgetting/internal/jsjson"
) )
@@ -181,6 +182,12 @@ func New() *CLI {
{"SUB_HOST", env("SUB_HOST", "")}, {"SUB_HOST", env("SUB_HOST", "")},
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN. // Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
{"NODE_PORT", env("NODE_PORT", "")}, {"NODE_PORT", env("NODE_PORT", "")},
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
{"RELAY_FROM", env("RELAY_FROM", "")},
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
{"CLI_ENV_FILE", cliEnv},
// Часовой аудит пиров на всех серверах — только у core. // Часовой аудит пиров на всех серверах — только у core.
{"AUDIT", audit}, {"AUDIT", audit},
{"METRICS_PEERS", env("METRICS_PEERS", "off")}, {"METRICS_PEERS", env("METRICS_PEERS", "off")},
@@ -196,6 +203,7 @@ func New() *CLI {
env: [][2]string{ env: [][2]string{
{"CORE_HOST", env("CORE_HOST", "")}, {"CORE_HOST", env("CORE_HOST", "")},
{"CORE_PORT", env("CORE_PORT", "")}, {"CORE_PORT", env("CORE_PORT", "")},
{"CORE_ENDPOINT", env("CORE_ENDPOINT", "")},
{"CORE_PIN", env("CORE_PIN", "")}, {"CORE_PIN", env("CORE_PIN", "")},
{"NODE_ID", env("NODE_ID", "")}, {"NODE_ID", env("NODE_ID", "")},
{"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")}, {"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")},
@@ -206,6 +214,12 @@ func New() *CLI {
}, },
pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"), 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 с // Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с
@@ -224,9 +238,29 @@ func New() *CLI {
default: default:
c.all = []string{"awgctrl", "ui"} 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 return c
} }
func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" }
func without(s []string, v string) []string {
var out []string
for _, x := range s {
if x != v {
out = append(out, x)
}
}
return out
}
func (c *CLI) envOf(name string) string { func (c *CLI) envOf(name string) string {
for _, kv := range c.svc[name].env { for _, kv := range c.svc[name].env {
if kv[0] == "PORT" { if kv[0] == "PORT" {
@@ -285,6 +319,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 { if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name {
return true return true
} }
if s.legacy == "" {
return false
}
// tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent. // tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent.
return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd)) return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd))
} }
@@ -476,10 +513,14 @@ func (c *CLI) startAll() {
} }
return return
} }
// Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN.
if contains(c.all, "relay") {
c.start("relay")
}
c.start("awgctrl") c.start("awgctrl")
var rest, labels []string var rest, labels []string
for _, n := range c.all { for _, n := range c.all {
if n != "awgctrl" { if n != "awgctrl" && n != "relay" {
rest = append(rest, n) rest = append(rest, n)
labels = append(labels, c.svc[n].label) labels = append(labels, c.svc[n].label)
} }
@@ -515,23 +556,9 @@ func contains(s []string, v string) bool {
// ── cli.env ───────────────────────────────────────────────────────────────── // ── cli.env ─────────────────────────────────────────────────────────────────
func updateEnvVar(content, key, value string) string { func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) }
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
if loc := re.FindStringIndex(content); loc != nil {
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
}
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
}
func removeEnvVar(content, key string) string { func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
var kept []string
for _, l := range strings.Split(content, "\n") {
if !strings.HasPrefix(l, key+"=") {
kept = append(kept, l)
}
}
return strings.Join(kept, "\n")
}
func (c *CLI) readEnvFile() string { func (c *CLI) readEnvFile() string {
raw, _ := os.ReadFile(c.envFile) raw, _ := os.ReadFile(c.envFile)
@@ -568,10 +595,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 передаёт сюда всё, что не роль сервиса. // 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 — без имени бинарника). Пусто — интерактивное меню. // Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
func Main(args []string) { func Main(args []string) {
@@ -608,6 +635,10 @@ func Main(args []string) {
c.joinCore(sub, hasSub, a2, hasA2) c.joinCore(sub, hasSub, a2, hasA2)
case "unjoin": case "unjoin":
c.unjoinCore() c.unjoinCore()
case "rehome":
c.rehome(sub, hasSub)
case "migrate":
c.migrateCmd(sub, a2, hasA2)
case "peers": case "peers":
c.peersCmd(sub, hasSub, a2, hasA2) c.peersCmd(sub, hasSub, a2, hasA2)
case "monitor": case "monitor":
+42 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -61,6 +62,10 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
sec, sok := j.Get2("s").(string) sec, sok := j.Get2("s").(string)
port, pok := isPosInt(j.Get2("p"), 65536) port, pok := isPosInt(j.Get2("p"), 65536)
id, iok := isPosInt(j.Get2("n"), 9007199254740992) 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) || if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) ||
!iok || !sok || !reJoinSec.MatchString(sec) { !iok || !sok || !reJoinSec.MatchString(sec) {
errorf(" Ссылка неверного формата или от другой версии панели") errorf(" Ссылка неверного формата или от другой версии панели")
@@ -71,7 +76,8 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
} }
hasPanel := os.Getenv("UI_PASS") != "" hasPanel := os.Getenv("UI_PASS") != ""
keepPanel := !hasFlag && hasPanel moved := c.panelMoved()
keepPanel := !hasFlag && hasPanel && !moved
content := c.readEnvFile() content := c.readEnvFile()
np := "off" np := "off"
if keepPanel { if keepPanel {
@@ -81,11 +87,18 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
{"JOIN_SECRET", sec}, {"NODE_PANEL", np}} { {"JOIN_SECRET", sec}, {"NODE_PANEL", np}} {
content = updateEnvVar(content, kv[0], kv[1]) 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) c.writeEnvFile(content)
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше // Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их). // некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
dropPeers := !keepPanel && hasPanel && c.askDropPanelPeers() // После переноса этими пирами управляет новая панель: снимать нечего.
dropPeers := !keepPanel && hasPanel && !moved && c.askDropPanelPeers()
if !keepPanel { if !keepPanel {
c.stop("ui") c.stop("ui")
} }
@@ -99,9 +112,35 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
tail = " · своя панель остановлена" tail = " · своя панель остановлена"
} }
fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail) fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail)
if relay != "" {
fmt.Println(" " + dim("Ретранслятор прежних портов на новую панель: "+relay))
}
c.restartFresh() 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() { func (c *CLI) unjoinCore() {
if !c.isNode { if !c.isNode {
fmt.Println(" Эта установка не подключена к core") fmt.Println(" Эта установка не подключена к core")
@@ -109,7 +148,7 @@ func (c *CLI) unjoinCore() {
} }
c.stop("agent") c.stop("agent")
content := c.readEnvFile() 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) content = removeEnvVar(content, k)
} }
c.writeEnvFile(content) c.writeEnvFile(content)
+292
View File
@@ -0,0 +1,292 @@
package cli
// `migrate export|import|cancel` — перенос панели с сервера «панель + VPN» на отдельную панель
// без VPN; архив и раскладка — internal/migrate (их же зовёт вкладка «Перенос»):
//
// старый: migrate export → архив: ui.db, ключи подписки и хаба, порты
// новый: migrate import → раскладывает архив, заводит ноду #0 и печатает join-строку
// старый: join '<строка>' → становится нодой #0 и поднимает ретранслятор прежних портов
import (
"crypto/rand"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func envPort(k string) int {
n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
if err != nil || n <= 0 || n > 65535 {
return 0
}
return n
}
func (c *CLI) paths() migrate.Paths {
return migrate.PathsFrom(func(env string) string { return c.svcEnv("ui", env) })
}
func (c *CLI) migrateCmd(sub, arg string, hasArg bool) {
switch sub {
case "export":
c.migrateExport(arg, hasArg)
case "import":
if !hasArg {
errorf(" Usage: awg-ctrl migrate import <архив>")
os.Exit(1)
}
c.migrateImport(arg)
case "cancel":
c.migrateCancel()
default:
errorf(" Usage: awg-ctrl migrate <export [файл]|import <архив>|cancel>")
os.Exit(1)
}
}
// ── export (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateExport(out string, hasOut bool) {
if c.isNode {
errorf(" Это нода: её панель — на core, переносить отсюда нечего")
os.Exit(1)
}
if !contains(c.svcList(), "awgctrl") {
errorf(" У этой панели нет своего VPN (LOCAL_NODE=off) — перенос рассчитан на сервер «панель + VPN»")
os.Exit(1)
}
dbFile := c.paths()["ui.db"]
if !exists(dbFile) {
errorf(" Нет базы панели: %s", dbFile)
os.Exit(1)
}
if !hasOut {
out = fmt.Sprintf("forgetting-migrate-%s.tar.gz", time.Now().Format("20060102-150405"))
}
fmt.Println()
fmt.Println(" Перенос панели на отдельный сервер. Сейчас:")
fmt.Println(" · панель и подписка sen:// здесь остановятся и больше не запустятся сами;")
fmt.Println(" · VPN продолжит работать: пиры и awg-ctrl не трогаются;")
fmt.Println(" · архив с базой панели и ключами подписки — " + bold(out))
if !c.confirmYes(" Продолжить?") {
return
}
c.stop("ui")
// Флаг — до снимка: перезапуск между ними поднял бы панель с базой, которая уже уехала.
content := updateEnvVar(c.readEnvFile(), "PANEL_MOVED", "on")
if err := c.writeEnvFile(content); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
if err := c.writeExport(out, dbFile); err != nil {
errorf(" Архив не создан: %v", err)
c.writeEnvFile(removeEnvVar(c.readEnvFile(), "PANEL_MOVED"))
os.Remove(out)
c.start("ui")
os.Exit(1)
}
fmt.Printf("\n %s Архив: %s %s\n", green("✓"), out, dim("(в нём ключ подписи — храните как пароль, после переноса удалите)"))
fmt.Println("\n Дальше:")
fmt.Println(" 1. Новая панель: install.sh, роль «только панель»")
fmt.Println(" 2. Скопируйте архив туда: " + dim("scp "+out+" root@<новая-панель>:"))
fmt.Println(" 3. На новой панели: " + bold("awg-ctrl migrate import "+filepath.Base(out)))
fmt.Println(" 4. Здесь — строку, которую она напечатает: " + bold("awg-ctrl join '<строка>'"))
fmt.Println(" " + dim("Передумали до шага 4: awg-ctrl migrate cancel"))
}
// svcList — сервисы роли без поправки на PANEL_MOVED и ретранслятор.
func (c *CLI) svcList() []string {
return without(without(c.all, "relay"), "ui")
}
func (c *CLI) writeExport(out, dbFile string) error {
db, err := migrate.OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
name, ip := "VPN", os.Getenv("SERVER_IP")
if h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health"); h != nil {
name = strOr(get(h, "server"), name)
if ip == "" {
ip = strOr(get(h, "ip"), "")
}
}
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
err = migrate.Write(f, db, c.paths(), exportMeta(c.version, name, ip, envPort("UI_PORT"), envPort("SUB_PORT"),
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT")))
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int) jsjson.Object {
return jsjson.Object{
{Key: "version", Val: version}, {Key: "created_at", Val: time.Now().Unix()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: uiPort}, {Key: "sub_port", Val: subPort}, {Key: "sub_tls", Val: subTLS}, {Key: "node_port", Val: nodePort},
}
}
// ── import (новая панель) ──────────────────────────────────────────────────
func (c *CLI) migrateImport(file string) {
if c.isNode {
errorf(" Это нода — импортировать панель нужно на core")
os.Exit(1)
}
if contains(c.svcList(), "awgctrl") {
errorf(" У этой панели свой VPN (LOCAL_NODE=on). Импорт — на панель без VPN: install.sh, роль «только панель»")
os.Exit(1)
}
f, err := os.Open(file)
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
b, err := migrate.Read(f)
f.Close()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer b.Close()
paths := c.paths()
if n, err := migrate.PanelData(paths["ui.db"]); err != nil || n > 0 {
errorf(" На этой панели уже есть данные (%s) — импорт их затёр бы. Нужна чистая панель.", paths["ui.db"])
os.Exit(1)
}
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
for host == "" || !reJoinHost.MatchString(host) {
host = strings.TrimSpace(c.ask(" Публичный адрес этой панели (IP или домен): "))
if host == "" {
return
}
}
plan, err := migrate.NewPlan(b, envPort, envPort("UI_PORT"), host)
if err != nil {
errorf(" %v (%s)", err, c.envFile)
os.Exit(1)
}
fmt.Println()
from := b.Str("server_ip")
if v := b.Str("version"); v != "" {
from += ", версия " + v
}
fmt.Printf(" Импорт панели «%s» (%s)\n", b.Str("server_name"), from)
fmt.Printf(" · подписка :%d, хаб нод :%d, адрес панели %s\n", plan.SubPort, plan.NodePort, host)
fmt.Println(" · прежние файлы панели уйдут в *.bak-<время>")
if !b.Has["sub_sign.key"] {
fmt.Println(" · " + dim("ключа подписи в архиве нет — на старом сервере подписка не была настроена"))
}
if !c.confirmYes(" Продолжить?") {
return
}
c.stop("ui")
secret := make([]byte, 16)
rand.Read(secret)
if err := migrate.Install(b, paths); err != nil {
errorf(" %v", err)
os.Exit(1)
}
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
errorf(" ui.db: %v", err)
os.Exit(1)
}
content := c.readEnvFile()
for _, kv := range plan.Env {
content = updateEnvVar(content, kv[0], kv[1])
}
if err := c.writeEnvFile(content); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
c.restartFresh()
// Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте.
var pin string
for range 50 {
if pin, err = migrate.CertPin(paths["node_tls.crt"]); err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
errorf(" Нет сертификата хаба %s — проверьте лог awg-ui и выдайте строку в панели (сервер #0)", paths["node_tls.crt"])
os.Exit(1)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
fmt.Printf("\n %s Панель перенесена. На старом сервере выполните (строка действует час):\n\n", green("✓"))
fmt.Println(" awg-ctrl join '" + join + "'")
fmt.Println()
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
}
// ── cancel (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateCancel() {
if !c.panelMoved() {
fmt.Println(" Переноса не было: панель на месте")
return
}
if c.isNode {
errorf(" Сервер уже нода новой панели — база там, и вернуть её сюда так нельзя.")
errorf(" Если всё же нужно: awg-ctrl unjoin, затем верните ui.db с новой панели вручную.")
os.Exit(1)
}
content := c.readEnvFile()
for _, k := range []string{"PANEL_MOVED", "RELAY"} {
content = removeEnvVar(content, k)
}
c.writeEnvFile(content)
fmt.Printf(" %s Перенос отменён, панель запускается здесь снова\n", green("✓"))
fmt.Println(" " + dim("Если архив уже импортирован на новую панель — не запускайте там ui, пока она не очищена"))
c.restartFresh()
}
// ── rehome (нода) ──────────────────────────────────────────────────────────
// rehome — панель переехала на новый адрес: тот же pin и id, другой CORE_HOST[:CORE_PORT].
func (c *CLI) rehome(addr string, hasAddr bool) {
if !c.isNode {
errorf(" Это не нода — менять адрес core нечему")
os.Exit(1)
}
host, port := addr, ""
if h, p, err := net.SplitHostPort(addr); err == nil {
host, port = h, p
}
if p, err := strconv.Atoi(port); !hasAddr || !reJoinHost.MatchString(host) || port != "" && (err != nil || p <= 0 || p > 65535) {
errorf(" Usage: awg-ctrl rehome <хост[:порт]> (адрес новой панели; pin и id ноды остаются)")
os.Exit(1)
}
content := updateEnvVar(c.readEnvFile(), "CORE_HOST", host)
if port != "" {
content = updateEnvVar(content, "CORE_PORT", port)
} else {
port = os.Getenv("CORE_PORT")
}
// Адрес, указанный руками, главнее того, что сообщила панель.
c.writeEnvFile(removeEnvVar(content, "CORE_ENDPOINT"))
fmt.Printf(" %s Core: %s:%s\n", green("✓"), host, port)
c.restartFresh()
}
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func decodeJoin(t *testing.T, s string) jsjson.Object {
t.Helper()
v, err := jsjson.Decode(fromB64url(strings.TrimPrefix(s, "awgjoin://")))
o, ok := v.(jsjson.Object)
if err != nil || !ok {
t.Fatalf("join-строка не разбирается: %v", err)
}
return o
}
// export берёт пути и порты из окружения сервисов, как их видит awg-ui.
func TestWriteExportFromEnv(t *testing.T) {
dir := t.TempDir()
sign := filepath.Join(dir, "sign")
os.WriteFile(sign, []byte("sign"), 0o600)
dbFile := filepath.Join(dir, "ui.db")
db, err := migrate.OpenDB(dbFile)
if err != nil {
t.Fatal(err)
}
db.Exec("CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
db.Exec("INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')")
db.Close()
for k, v := range map[string]string{
"AWG_ROOT": dir, "UI_DB_FILE": dbFile, "SUB_SIGN_KEY_FILE": sign, "AWGCTRL_PORT": "1", "SERVER_IP": "198.51.100.1",
"SUB_PORT": "40123", "SUB_TLS": "on", "NODE_PORT": "40500", "UI_PORT": "33000",
"SUB_TLS_KEY_FILE": filepath.Join(dir, "нет"), "SUB_TLS_CERT_FILE": filepath.Join(dir, "нет"),
"NODE_TLS_KEY_FILE": filepath.Join(dir, "нет"), "NODE_TLS_CERT_FILE": filepath.Join(dir, "нет"),
} {
t.Setenv(k, v)
}
out := filepath.Join(dir, "bundle.tar.gz")
if err := New().writeExport(out, dbFile); err != nil {
t.Fatal(err)
}
if st, _ := os.Stat(out); st.Mode().Perm() != 0o600 {
t.Errorf("права архива %v — в нём ключ подписи", st.Mode().Perm())
}
f, _ := os.Open(out)
b, err := migrate.Read(f)
f.Close()
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Str("server_ip") != "198.51.100.1" || b.Str("server_name") != "VPN" ||
b.Num("sub_port") != 40123 || b.Num("node_port") != 40500 || b.Num("ui_port") != 33000 || b.Meta.Get2("sub_tls") != true ||
!b.Has["sub_sign.key"] || b.Has["sub_tls.key"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
}
func TestRelayRoutes(t *testing.T) {
j := decodeJoin(t, migrate.JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123))
t.Setenv("SUB_PORT", "40123")
t.Setenv("NODE_PORT", "40400")
t.Setenv("UI_PORT", "32000")
if got, want := relayRoutes("203.0.113.7", 40500, j), "40123=203.0.113.7:40123,40400=203.0.113.7:40500,32000=203.0.113.7:33100"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
// Строка, выданная панелью заново (без ui/sub): подписка — на тот же порт, панель не пересылаем.
t.Setenv("NODE_PORT", "")
plain := decodeJoin(t, migrate.JoinString("2001:db8::7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 0, 0))
if got, want := relayRoutes("2001:db8::7", 40500, plain), "40123=[2001:db8::7]:40123"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
}
+43
View File
@@ -0,0 +1,43 @@
// Package envfile — правка cli.env (KEY=value построчно): CLI и панель пишут в него одинаково.
package envfile
import (
"os"
"regexp"
"strings"
)
// Update — как String.replace без g: первое KEY=…, а нет его — строка в конец.
func Update(content, key, value string) string {
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
if loc := re.FindStringIndex(content); loc != nil {
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
}
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
}
func Remove(content, key string) string {
var kept []string
for _, l := range strings.Split(content, "\n") {
if !strings.HasPrefix(l, key+"=") {
kept = append(kept, l)
}
}
return strings.Join(kept, "\n")
}
// Set — задать set и убрать remove в файле. Новый файл — 0600 (в нём пароль панели).
func Set(file string, set [][2]string, remove ...string) error {
raw, err := os.ReadFile(file)
if err != nil && !os.IsNotExist(err) {
return err
}
content := string(raw)
for _, kv := range set {
content = Update(content, kv[0], kv[1])
}
for _, k := range remove {
content = Remove(content, k)
}
return os.WriteFile(file, []byte(content), 0o600)
}
+382
View File
@@ -0,0 +1,382 @@
// Package migrate — перенос панели с сервера «панель + VPN» на отдельную панель без VPN: архив
// (ui.db, ключи подписки и хаба, порты), его раскладка на новой панели и нода #0. Общий для
// CLI (`awg-control migrate`) и вкладки «Перенос» в панели. Порядок и смысл —
// docs/node-protocol.md, «Перенос панели».
//
// Старый сервер остаётся нодой #0: id сервера в ui.db не меняется, поэтому конфиг клиента
// (servers[] и ключи) тот же байт в байт, а пиры на его awg-ctrl уже принадлежат области
// перенесённой панели (sub_meta.panel_id едет вместе с ui.db).
package migrate
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64url = base64.RawURLEncoding
const (
Version = 1
maxFile = 1 << 30 // ui.db крупнее гигабайта — явно не то
JoinTTL = 3600 // как у панели: одноразовый секрет живёт час
)
// File — что едет в архиве: имя внутри архива и переменная пути в окружении awg-ui.
type File struct {
Name, Env string
Required bool
}
var Files = []File{
{"ui.db", "UI_DB_FILE", true},
// 🛑 Ключ подписи и TLS подписки зашиты во все выданные sen://, сертификат хаба — во все
// join-строки и подключённые ноды: без них перенос ломает и ссылки, и ноды.
{"sub_sign.key", "SUB_SIGN_KEY_FILE", false},
{"sub_tls.key", "SUB_TLS_KEY_FILE", false},
{"sub_tls.crt", "SUB_TLS_CERT_FILE", false},
{"node_tls.key", "NODE_TLS_KEY_FILE", false},
{"node_tls.crt", "NODE_TLS_CERT_FILE", false},
}
// Paths — имя файла в архиве → путь на этой машине.
type Paths map[string]string
// PathsFrom — пути из переменных окружения awg-ui (get(env) → путь).
func PathsFrom(get func(env string) string) Paths {
p := Paths{}
for _, f := range Files {
p[f.Name] = get(f.Env)
}
return p
}
// OpenDB — ui.db тем же драйвером, что у awg-ui.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", "file:"+(&url.URL{Path: path}).EscapedPath()+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// ── Архив ───────────────────────────────────────────────────────────────────
// Write — архив в w: снимок db (VACUUM INTO — целостный, вместе с WAL), файлы из p и meta
// (+ panel_id из базы). Отсутствующие необязательные файлы пропускаются.
func Write(w io.Writer, db *sql.DB, p Paths, meta jsjson.Object) error {
tmp, err := os.MkdirTemp("", "forgetting-migrate-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
snap := filepath.Join(tmp, "ui.db")
if _, err := db.Exec("VACUUM INTO ?", snap); err != nil {
return fmt.Errorf("снимок ui.db: %w", err)
}
var panelID string
db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&panelID)
meta = append(jsjson.Object{{Key: "v", Val: Version}, {Key: "panel_id", Val: panelID}}, meta...)
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
add := func(name string, data []byte) error {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
err = add("meta.json", jsjson.MustMarshal(meta))
for _, f := range Files {
if err != nil {
break
}
src := p[f.Name]
if f.Name == "ui.db" {
src = snap
}
raw, rerr := os.ReadFile(src)
if errors.Is(rerr, os.ErrNotExist) && !f.Required {
continue
}
if rerr != nil {
err = rerr
break
}
err = add(f.Name, raw)
}
if cerr := tw.Close(); err == nil {
err = cerr
}
if cerr := gz.Close(); err == nil {
err = cerr
}
return err
}
type Bundle struct {
Dir string
Meta jsjson.Object
Has map[string]bool
}
func (b *Bundle) Close() { os.RemoveAll(b.Dir) }
func (b *Bundle) Num(k string) int {
f, _ := b.Meta.Get2(k).(float64)
return int(f)
}
func (b *Bundle) Str(k string) string {
s, _ := b.Meta.Get2(k).(string)
return s
}
// Read — распаковка во временный каталог; в архиве допустимы только известные имена.
func Read(r io.Reader) (*Bundle, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("не архив переноса: %w", err)
}
dir, err := os.MkdirTemp("", "forgetting-import-")
if err != nil {
return nil, err
}
b := &Bundle{Dir: dir, Has: map[string]bool{}}
fail := func(err error) (*Bundle, error) { b.Close(); return nil, err }
allowed := map[string]bool{"meta.json": true}
for _, f := range Files {
allowed[f.Name] = true
}
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fail(fmt.Errorf("архив повреждён: %w", err))
}
if h.Typeflag != tar.TypeReg || !allowed[h.Name] || b.Has[h.Name] || h.Size > maxFile {
return fail(fmt.Errorf("в архиве лишнее: %q", h.Name))
}
dst, err := os.OpenFile(filepath.Join(dir, h.Name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
_, err = io.Copy(dst, io.LimitReader(tr, maxFile))
if cerr := dst.Close(); err == nil {
err = cerr
}
}
if err != nil {
return fail(err)
}
b.Has[h.Name] = true
}
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
var v any
if err == nil {
v, err = jsjson.Decode(raw)
}
meta, ok := v.(jsjson.Object)
if err != nil || !ok || meta.Get2("v") != float64(Version) || !b.Has["ui.db"] {
return fail(errors.New("не архив переноса или от другой версии (нужны meta.json v1 и ui.db)"))
}
b.Meta = meta
return b, nil
}
// PanelData — сколько в базе того, что перенос затёр бы (мастер-ключи, ноды, ключи API).
func PanelData(file string) (int, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
return 0, nil
}
db, err := OpenDB(file)
if err != nil {
return 0, err
}
defer db.Close()
return Count(db), nil
}
func Count(db *sql.DB) int {
total := 0
for _, t := range []string{"master_keys", "nodes", "api_keys"} {
var n int
// Таблицы может не быть — awg-ui ещё не запускался.
if db.QueryRow("SELECT COUNT(*) FROM "+t).Scan(&n) == nil {
total += n
}
}
return total
}
// ── Раскладка на новой панели ──────────────────────────────────────────────
// Plan — порты и переменные cli.env новой панели. Подписка и хаб — на прежних портах: они в
// ссылках, у нод и в DNS, если адрес — домен.
type Plan struct {
SubPort, NodePort, UIPort int
Host string
Env [][2]string
}
// NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес.
func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) {
p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host}
if p.SubPort == 0 {
p.SubPort = env("SUB_PORT")
}
if p.NodePort == 0 {
p.NodePort = env("NODE_PORT")
}
if p.NodePort == 0 {
return nil, errors.New("приём нод не настроен (NODE_PORT): старый сервер подключится к панели нодой")
}
if p.UIPort != 0 && (p.UIPort == p.SubPort || p.UIPort == p.NodePort) || p.SubPort != 0 && p.SubPort == p.NodePort {
return nil, fmt.Errorf("порты пересекаются: панель %d, подписка %d, хаб %d — смените UI_PORT", p.UIPort, p.SubPort, p.NodePort)
}
for _, kv := range [][2]string{{"SUB_PORT", strconv.Itoa(p.SubPort)}, {"NODE_PORT", strconv.Itoa(p.NodePort)}, {"SUB_HOST", host}} {
if kv[1] != "0" && kv[1] != "" {
p.Env = append(p.Env, kv)
}
}
tlsOn := "off"
if b.Has["sub_tls.crt"] && b.Meta.Get2("sub_tls") == true {
tlsOn = "on"
}
p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn})
// С этого момента панель считает, кто из клиентов и нод узнал её новый адрес.
p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)})
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
if ip := b.Str("server_ip"); ip != "" {
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
return p, nil
}
// TrustRelay — ещё один адрес ретранслятора: старый сервер может выходить в сеть не с того IP,
// что у него в SERVER_IP (NAT, второй адрес). Панель видит, откуда пришёл архив, — его и берём.
func (p *Plan) TrustRelay(ip string) {
if ip == "" {
return
}
for i, kv := range p.Env {
if kv[0] == "RELAY_FROM" {
for _, have := range strings.Split(kv[1], ",") {
if have == ip {
return
}
}
p.Env[i][1] += "," + ip
return
}
}
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
}
// Install — файлы архива на свои места; прежние уходят в *.bak-<время>.
func Install(b *Bundle, p Paths) error {
stamp := time.Now().Format("20060102-150405")
for _, f := range Files {
if !b.Has[f.Name] {
continue
}
dst := p[f.Name]
olds := []string{dst}
if f.Name == "ui.db" {
olds = append(olds, dst+"-wal", dst+"-shm")
}
for _, o := range olds {
if _, err := os.Stat(o); err == nil {
if err := os.Rename(o, o+".bak-"+stamp); err != nil {
return fmt.Errorf("не удалось отложить %s: %w", o, err)
}
}
}
raw, err := os.ReadFile(filepath.Join(b.Dir, f.Name))
if err == nil {
err = os.MkdirAll(filepath.Dir(dst), 0o700)
}
if err == nil {
err = os.WriteFile(dst, raw, 0o600)
}
if err != nil {
return fmt.Errorf("не удалось записать %s: %w", dst, err)
}
}
return nil
}
// AddNodeZero — старый сервер в реестре нод под прежним id 0, ждёт первого подключения.
func AddNodeZero(dbFile, name string, secret []byte) error {
if name == "" || len([]rune(name)) > 40 || strings.ContainsAny(name, "\r\n<>") {
name = "VPN"
}
db, err := OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
h := sha256.Sum256(secret)
now := time.Now().Unix()
if _, err := db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, ?, ?, ?, ?)",
name, h[:], now+JoinTTL, now); err != nil {
return fmt.Errorf("нода #0 не заведена (база со старой версии? обновите старый сервер): %w", err)
}
return nil
}
// CertPin — SHA-256 SPKI сертификата, base64url: то же, что pin в join-строке.
func CertPin(file string) (string, error) {
raw, err := os.ReadFile(file)
if err != nil {
return "", err
}
blk, _ := pem.Decode(raw)
if blk == nil {
return "", errors.New("не PEM")
}
cert, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64url.EncodeToString(sum[:]), nil
}
// JoinString — как у панели (nodes.go), плюс ui/sub: куда ретранслятору старого сервера слать
// прежние порты панели и подписки. Остальные читатели строки лишних полей не видят.
func JoinString(host string, port int, pin string, id int64, secret []byte, uiPort, subPort int) string {
o := jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: port},
{Key: "pin", Val: pin}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}
if uiPort > 0 {
o = append(o, jsjson.KV{Key: "ui", Val: uiPort})
}
if subPort > 0 {
o = append(o, jsjson.KV{Key: "sub", Val: subPort})
}
return "awgjoin://" + b64url.EncodeToString(jsjson.MustMarshal(o))
}
+176
View File
@@ -0,0 +1,176 @@
package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testDB(t *testing.T, file string) {
t.Helper()
db, err := OpenDB(file)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, q := range []string{
"CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
"INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')",
"CREATE TABLE nodes (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pub TEXT, join_hash BLOB, join_expires INTEGER, created_at INTEGER NOT NULL, last_seen INTEGER, version TEXT)",
"CREATE TABLE master_keys (id INTEGER PRIMARY KEY)",
"INSERT INTO master_keys VALUES (1)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(err)
}
}
}
// Архив → раскладка: всё, что зашито в ссылки и у нод, едет как есть; база — целостным
// снимком, в котором новая панель заводит ноду #0.
func TestBundleRoundTrip(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
paths := func(dir string) Paths {
return PathsFrom(func(env string) string { return filepath.Join(dir, strings.ToLower(env)) })
}
from, to := paths(src), paths(dst)
testDB(t, from["ui.db"])
for name, v := range map[string]string{"sub_sign.key": "sign", "sub_tls.key": "tlskey", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
os.WriteFile(from[name], []byte(v), 0o600)
}
os.WriteFile(to["sub_sign.key"], []byte("прежний"), 0o600)
db, _ := OpenDB(from["ui.db"])
var buf bytes.Buffer
err := Write(&buf, db, from, jsjson.Object{{Key: "server_ip", Val: "198.51.100.1"}, {Key: "sub_port", Val: 40123},
{Key: "sub_tls", Val: true}, {Key: "node_port", Val: 40500}})
db.Close()
if err != nil {
t.Fatal(err)
}
b, err := Read(&buf)
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Num("sub_port") != 40123 || b.Has["node_tls.crt"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
if n, err := PanelData(to["ui.db"]); err != nil || n != 0 {
t.Errorf("PanelData(нет файла) = %d, %v", n, err)
}
plan, err := NewPlan(b, func(string) int { return 0 }, 33000, "203.0.113.7")
if err != nil {
t.Fatal(err)
}
got := regexp.MustCompile(`"MOVED_IN_AT","\d+"`).ReplaceAllString(string(jsjson.MustMarshal(plan.Env)), `"MOVED_IN_AT","…"`)
if got != `[["SUB_PORT","40123"],["NODE_PORT","40500"],["SUB_HOST","203.0.113.7"],["SUB_TLS","on"],["MOVED_IN_AT","…"],["RELAY_FROM","198.51.100.1"]]` {
t.Errorf("env: %s", got)
}
if _, err := NewPlan(b, func(string) int { return 0 }, 40500, "h"); err == nil {
t.Error("UI_PORT = порт хаба: ожидали ошибку")
}
if err := Install(b, to); err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{"sub_sign.key": "sign", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
if raw, _ := os.ReadFile(to[name]); string(raw) != want {
t.Errorf("%s: %q", name, raw)
}
}
if baks, _ := filepath.Glob(to["sub_sign.key"] + ".bak-*"); len(baks) != 1 {
t.Errorf("прежний ключ не отложен: %v", baks)
}
if n, _ := PanelData(to["ui.db"]); n != 1 {
t.Errorf("PanelData(снимок) = %d", n)
}
if err := AddNodeZero(to["ui.db"], "VPN\n<script>", []byte("0123456789abcdef")); err != nil {
t.Fatal(err)
}
db, _ = OpenDB(to["ui.db"])
var name string
db.QueryRow("SELECT name FROM nodes WHERE id = 0").Scan(&name)
db.Close()
if name != "VPN" {
t.Errorf("нода #0: %q", name)
}
if err := AddNodeZero(to["ui.db"], "VPN", []byte("x")); err == nil {
t.Error("вторая нода #0: ожидали ошибку")
}
}
func TestReadRejectsForeignEntries(t *testing.T) {
for name, entries := range map[string][]string{
"лишний файл": {"meta.json", "ui.db", "../../etc/passwd"},
"без базы": {"meta.json"},
"повтор": {"meta.json", "ui.db", "ui.db"},
} {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, e := range entries {
data := []byte("x")
if e == "meta.json" {
data = []byte(`{"v":1}`)
}
tw.WriteHeader(&tar.Header{Name: e, Mode: 0o600, Size: int64(len(data))})
tw.Write(data)
}
tw.Close()
gz.Close()
if b, err := Read(&buf); err == nil {
b.Close()
t.Errorf("%s: архив принят", name)
}
}
}
func TestJoinString(t *testing.T) {
s := JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123)
raw, _ := b64url.DecodeString(strings.TrimPrefix(s, "awgjoin://"))
// Поля сверх v1 — в конце: install.sh и старые CLI их не замечают.
if want := `{"v":1,"h":"203.0.113.7","p":40500,"pin":"` + strings.Repeat("A", 43) + `","n":0,"s":"MDEyMzQ1Njc4OWFiY2RlZg","ui":33100,"sub":40123}`; string(raw) != want {
t.Errorf("join: %s", raw)
}
}
func TestCodeAndSeal(t *testing.T) {
c := Code{Host: "2001:db8::7", Port: 33000, Key: NewKey()}
got, err := ParseCode(" " + c.String() + "\n")
if err != nil || got.Host != c.Host || got.Port != c.Port || !bytes.Equal(got.Key, c.Key) {
t.Fatalf("ParseCode: %+v %v", got, err)
}
if u := got.URL(); u != "http://[2001:db8::7]:33000/migrate/v1/import" {
t.Errorf("URL: %s", u)
}
for _, bad := range []string{"", "awgmove://", "awgjoin://e30", CodePrefix + "e30",
Code{Host: "h/../x", Port: 1, Key: c.Key}.String(), Code{Host: "h", Port: 0, Key: c.Key}.String(), Code{Host: "h", Port: 1, Key: []byte("short")}.String()} {
if _, err := ParseCode(bad); err == nil {
t.Errorf("ParseCode(%q): ожидали ошибку", bad)
}
}
box := SealRequest(c.Key, []byte("архив"))
if plain, err := OpenRequest(c.Key, box); err != nil || string(plain) != "архив" {
t.Errorf("OpenRequest: %q %v", plain, err)
}
if _, err := OpenResponse(c.Key, box); err == nil {
t.Error("запрос открылся как ответ")
}
if _, err := OpenRequest(NewKey(), box); err == nil {
t.Error("открылся чужим ключом")
}
box[len(box)-1] ^= 1
if _, err := OpenRequest(c.Key, box); err == nil {
t.Error("открылся повреждённый")
}
}
+115
View File
@@ -0,0 +1,115 @@
package migrate
// Перенос из панели в панель (вкладка «Перенос»): новая панель выдаёт одноразовый код
// awgmove://, старая шлёт по нему архив на её порт панели. Порт панели обычно — голый HTTP,
// а в архиве ключ подписи, поэтому и запрос, и ответ зашифрованы AES-256-GCM ключом из кода:
// без кода архив не прочесть и не подделать, а верный тег и есть доказательство, что код знают.
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
CodePrefix = "awgmove://"
keyLen = 32
// Разные AAD — ответ нельзя выдать за запрос и наоборот.
aadRequest = "awgmove-v1 request"
aadResponse = "awgmove-v1 response"
)
// Code — что старая панель узнаёт из кода: куда слать и чем шифровать.
type Code struct {
Host string
Port int
Key []byte
}
func NewKey() []byte {
k := make([]byte, keyLen)
rand.Read(k)
return k
}
func (c Code) String() string {
return CodePrefix + b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{
{Key: "v", Val: Version}, {Key: "h", Val: c.Host}, {Key: "p", Val: c.Port}, {Key: "k", Val: b64url.EncodeToString(c.Key)}}))
}
func ParseCode(s string) (Code, error) {
bad := errors.New("код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели")
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, CodePrefix) {
return Code{}, bad
}
raw, err := b64url.DecodeString(s[len(CodePrefix):])
if err != nil {
return Code{}, bad
}
v, err := jsjson.Decode(raw)
o, ok := v.(jsjson.Object)
if err != nil || !ok || o.Get2("v") != float64(Version) {
return Code{}, bad
}
host, _ := o.Get2("h").(string)
port, _ := o.Get2("p").(float64)
k, _ := o.Get2("k").(string)
key, err := b64url.DecodeString(k)
if host == "" || len(host) > 253 || strings.ContainsAny(host, "/?#@ ") || port <= 0 || port > 65535 ||
port != float64(int(port)) || err != nil || len(key) != keyLen {
return Code{}, bad
}
return Code{Host: host, Port: int(port), Key: key}, nil
}
// URL — куда слать архив.
func (c Code) URL() string {
h := c.Host
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
h = "[" + h + "]"
}
return "http://" + h + ":" + strconv.Itoa(c.Port) + "/migrate/v1/import"
}
func gcm(key []byte) (cipher.AEAD, error) {
blk, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(blk)
}
func seal(key []byte, aad string, plain []byte) []byte {
g, err := gcm(key)
if err != nil {
panic(err) // ключ всегда 32 байта
}
nonce := make([]byte, g.NonceSize())
rand.Read(nonce)
return g.Seal(nonce, nonce, plain, []byte(aad))
}
var ErrSeal = errors.New("не расшифровать: код не тот или данные повреждены")
func open(key []byte, aad string, box []byte) ([]byte, error) {
g, err := gcm(key)
if err != nil || len(box) < g.NonceSize() {
return nil, ErrSeal
}
plain, err := g.Open(nil, box[:g.NonceSize()], box[g.NonceSize():], []byte(aad))
if err != nil {
return nil, ErrSeal
}
return plain, nil
}
func SealRequest(key, plain []byte) []byte { return seal(key, aadRequest, plain) }
func OpenRequest(key, box []byte) ([]byte, error) { return open(key, aadRequest, box) }
func SealResponse(key, plain []byte) []byte { return seal(key, aadResponse, plain) }
func OpenResponse(key, box []byte) ([]byte, error) { return open(key, aadResponse, box) }
+159
View File
@@ -0,0 +1,159 @@
// Package relay — ретранслятор TCP на сервере, с которого переехала панель (`awg-control
// migrate`). В выданных ссылках sen://, у подключённых нод и у клиентов внешнего API остался
// старый адрес: relay слушает прежние порты подписки, хаба нод и панели и пересылает соединения
// на новую панель как есть — TLS идёт насквозь, ключи и сертификаты у панели прежние. Перед
// данными идёт заголовок PROXY v1 с адресом клиента: панель верит ему только от RELAY_FROM.
package relay
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
)
const dialTimeout = 10 * time.Second
// Route — одна пересылка: локальный порт → адрес новой панели.
type Route struct {
Listen int
Target string // host:port
}
// ParseRoutes — RELAY: «порт=хост:порт» через запятую.
func ParseRoutes(s string) ([]Route, error) {
var out []Route
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
l, t, ok := strings.Cut(part, "=")
port, err := strconv.Atoi(l)
if !ok || err != nil || port <= 0 || port > 65535 {
return nil, fmt.Errorf("relay: неверная пересылка %q (нужно порт=хост:порт)", part)
}
host, tp, err := net.SplitHostPort(t)
if n, perr := strconv.Atoi(tp); err != nil || host == "" || perr != nil || n <= 0 || n > 65535 {
return nil, fmt.Errorf("relay: неверный адрес %q", t)
}
out = append(out, Route{port, t})
}
if len(out) == 0 {
return nil, errors.New("relay: RELAY пуст — пересылать нечего")
}
return out, nil
}
// proxyHeader — строка PROXY v1 для соединения c.
func proxyHeader(c net.Conn) string {
src, ok1 := c.RemoteAddr().(*net.TCPAddr)
dst, ok2 := c.LocalAddr().(*net.TCPAddr)
if !ok1 || !ok2 {
return "PROXY UNKNOWN\r\n"
}
sip, dip := src.IP, dst.IP
fam := "TCP6"
if s4, d4 := sip.To4(), dip.To4(); s4 != nil && d4 != nil {
fam, sip, dip = "TCP4", s4, d4
} else if s4 != nil || d4 != nil {
// Смешанные семейства (v4-mapped на v6-сокете и т. п.) — оба в v6.
sip, dip = sip.To16(), dip.To16()
}
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, sip, dip, src.Port, dst.Port)
}
// pipe — копирование в обе стороны; конец одной стороны закрывает запись в другую.
func pipe(a, b net.Conn) {
var wg sync.WaitGroup
cp := func(dst, src net.Conn) {
defer wg.Done()
io.Copy(dst, src)
if tc, ok := dst.(*net.TCPConn); ok {
tc.CloseWrite()
} else {
dst.Close()
}
}
wg.Add(2)
go cp(a, b)
go cp(b, a)
wg.Wait()
a.Close()
b.Close()
}
func serve(ctx context.Context, ln net.Listener, r Route, errorf func(string, ...any)) {
var d net.Dialer
d.Timeout = dialTimeout
for {
c, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
errorf("relay: :%d: %v", r.Listen, err)
time.Sleep(time.Second)
continue
}
go func() {
up, err := d.DialContext(ctx, "tcp", r.Target)
if err != nil {
errorf("relay: :%d → %s: %v", r.Listen, r.Target, err)
c.Close()
return
}
if _, err := io.WriteString(up, proxyHeader(c)); err != nil {
c.Close()
up.Close()
return
}
pipe(c, up)
}()
}
}
// Run слушает все порты и пересылает до отмены ctx. Порт, который не открылся, — ошибка сразу:
// значит, его ещё держит прежняя панель.
func Run(ctx context.Context, routes []Route, logf, errorf func(string, ...any)) error {
var lns []net.Listener
for _, r := range routes {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(r.Listen))
if err != nil {
for _, l := range lns {
l.Close()
}
return fmt.Errorf("relay: порт %d: %w", r.Listen, err)
}
lns = append(lns, ln)
logf("relay: :%d → %s", r.Listen, r.Target)
}
for i, r := range routes {
go serve(ctx, lns[i], r, errorf)
}
<-ctx.Done()
for _, l := range lns {
l.Close()
}
return ctx.Err()
}
// Main — роль `awg-control relay`: пересылки из RELAY.
func Main(ctx context.Context) error {
routes, err := ParseRoutes(os.Getenv("RELAY"))
if err != nil {
return err
}
logf := func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
errorf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
return Run(ctx, routes, logf, errorf)
}
+102
View File
@@ -0,0 +1,102 @@
package relay
import (
"bufio"
"context"
"io"
"net"
"strconv"
"strings"
"testing"
"time"
)
func TestParseRoutes(t *testing.T) {
r, err := ParseRoutes(" 40123=203.0.113.7:40123, 8443=[2001:db8::1]:9000 ,")
if err != nil || len(r) != 2 || r[0] != (Route{40123, "203.0.113.7:40123"}) || r[1] != (Route{8443, "[2001:db8::1]:9000"}) {
t.Fatalf("ParseRoutes: %v %v", r, err)
}
for _, bad := range []string{"", ",", "40123", "0=h:1", "70000=h:1", "1=h", "1=:5", "1=h:0", "x=h:1"} {
if _, err := ParseRoutes(bad); err == nil {
t.Errorf("ParseRoutes(%q): ожидали ошибку", bad)
}
}
}
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
// Поток идёт насквозь в обе стороны, первой строкой у панели — PROXY с адресом клиента.
func TestRelayForwardsWithProxyHeader(t *testing.T) {
target, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer target.Close()
got := make(chan string, 2)
go func() {
c, err := target.Accept()
if err != nil {
return
}
defer c.Close()
r := bufio.NewReader(c)
hdr, _ := r.ReadString('\n')
got <- hdr
body, _ := io.ReadAll(r)
got <- string(body)
io.WriteString(c, "pong")
}()
port := freePort(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errs := make(chan error, 1)
go func() {
errs <- Run(ctx, []Route{{port, target.Addr().String()}}, func(string, ...any) {}, func(f string, a ...any) { t.Logf(f, a...) })
}()
var cl net.Conn
for range 50 {
if cl, err = net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))); err == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, "ping")
cl.(*net.TCPConn).CloseWrite()
hdr := <-got
src := cl.LocalAddr().(*net.TCPAddr)
if want := "PROXY TCP4 127.0.0.1 127.0.0.1 " + strconv.Itoa(src.Port) + " " + strconv.Itoa(port) + "\r\n"; hdr != want {
t.Errorf("заголовок %q, ожидали %q", hdr, want)
}
if body := <-got; body != "ping" {
t.Errorf("до панели дошло %q", body)
}
cl.SetReadDeadline(time.Now().Add(3 * time.Second))
if back, _ := io.ReadAll(cl); string(back) != "pong" {
t.Errorf("обратно пришло %q", back)
}
// Занятый порт — ошибка сразу: значит, его держит прежняя панель.
busy, _ := net.Listen("tcp", ":0")
defer busy.Close()
if err := Run(context.Background(), []Route{{busy.Addr().(*net.TCPAddr).Port, "127.0.0.1:1"}}, func(string, ...any) {}, t.Logf); err == nil ||
!strings.Contains(err.Error(), "порт") {
t.Errorf("занятый порт: %v", err)
}
cancel()
<-errs
}
+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)
}
}
+454
View File
@@ -0,0 +1,454 @@
package ui
// Вкладка «Перенос»: панель переезжает с сервера «панель + VPN» на отдельную панель без VPN
// прямо из интерфейса, без архива в руках. Те же шаги, что `awg-control migrate` (общий код —
// internal/migrate), только архив идёт из панели в панель:
//
// новая: POST /ui/migrate/code → одноразовый код awgmove:// (адрес, порт панели, ключ)
// старая: POST /ui/migrate/export → замораживает /sub/v1, шлёт зашифрованный архив на
// POST /migrate/v1/import новой панели
// новая: раскладывает архив, заводит ноду #0, отвечает join-строкой и перезапускается
// старая: пишет PANEL_MOVED и запускает `awg-control join` — становится нодой #0 с ретранслятором
//
// Перезапуск и join делает CLI в отдельном процессе: панель останавливает сама себя.
import (
"bytes"
"database/sql"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
const (
moveCodeTTL = time.Hour
moveMaxFails = 5
moveMaxBundle = 1 << 30
moveTimeout = 10 * time.Minute
)
// Состояние переноса на этой панели (в памяти: перезапуск его сбрасывает, и так и надо).
const (
moveIdle = ""
moveSending = "sending" // старая: архив в пути, /sub/v1 заморожен
moveMoved = "moved" // старая: новая приняла, сейчас будет join
moveUncertain = "uncertain" // старая: архив ушёл, а ответа нет — новая могла его принять
moveImported = "imported" // новая: архив принят, сейчас перезапуск
)
type Migration struct {
a *App
envFile string // cli.env: сюда пишутся порты, PANEL_MOVED, RELAY_FROM
run func(args ...string) // `awg-control …` отдельным процессом; в тестах — заглушка
mu sync.Mutex
key []byte // новая: ключ действующего кода
host string
expires time.Time
fails int
state string
errText string
target string // старая: адрес новой панели
}
func newMigration(a *App) *Migration {
m := &Migration{a: a, envFile: os.Getenv("CLI_ENV_FILE")}
m.run = m.spawn
return m
}
func (a *App) filePath(env, name string) string {
if v := os.Getenv(env); v != "" {
return v
}
return filepath.Join(a.baseDir, name)
}
// paths — где awg-ui держит файлы из архива (те же правила, что при их загрузке).
func (m *Migration) paths() migrate.Paths {
return migrate.PathsFrom(func(env string) string {
for _, f := range migrate.Files {
if f.Env == env {
return m.a.filePath(env, f.Name)
}
}
return ""
})
}
// canExport / canAccept — "" или причина, почему нельзя.
func (m *Migration) canExport() string {
switch {
case m.envFile == "":
return "Панель запущена не через awg-control — перенос недоступен"
case os.Getenv("CORE_HOST") != "":
return "Этот сервер уже нода другой панели"
case !m.a.nodes.local:
return "У этой панели нет своего VPN: переносить её как сервер-ноду нечего"
}
return ""
}
func (m *Migration) canAccept() string {
switch {
case m.envFile == "":
return "Панель запущена не через awg-control — перенос недоступен"
case m.a.nodes.local:
return "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
case !m.a.nodes.hubReady():
return "Не настроен приём нод (NODE_PORT): старый сервер подключится сюда нодой"
case migrate.Count(m.a.db) > 0:
return "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую"
}
return ""
}
func (m *Migration) router() *Router {
rt := newRouter()
rt.Get("/", m.status)
rt.Post("/code", m.newCode)
rt.Delete("/code", func(c *Ctx) bool {
m.mu.Lock()
m.key = nil
m.mu.Unlock()
return m.status(c)
})
rt.Post("/export", m.export)
rt.Post("/cancel", m.cancel)
rt.Delete("/retire", func(c *Ctx) bool {
if err := envfile.Set(m.envFile, nil, "MOVED_IN_AT"); err != nil {
return c.Error(500, err.Error())
}
os.Unsetenv("MOVED_IN_AT")
return m.status(c)
})
return rt
}
func (m *Migration) status(c *Ctx) bool {
m.mu.Lock()
state, errText, target := m.state, m.errText, m.target
var code any
if m.key != nil && time.Now().Before(m.expires) {
code = jsjson.Object{{Key: "code", Val: migrate.Code{Host: m.host, Port: m.a.port, Key: m.key}.String()},
{Key: "expires_in", Val: int(time.Until(m.expires).Seconds())}}
}
m.mu.Unlock()
o := jsjson.Object{{Key: "state", Val: state}, {Key: "error", Val: orNil(errText)}, {Key: "target", Val: orNil(target)}, {Key: "code", Val: code}}
// После приёма база закрыта до перезапуска — что можно, уже не считаем.
if state != moveImported {
o = append(o, jsjson.KV{Key: "export", Val: orNil(m.canExport())}, jsjson.KV{Key: "accept", Val: orNil(m.canAccept())},
jsjson.KV{Key: "retire", Val: m.retire()})
}
return c.JSON(200, o)
}
// ── Новая панель ────────────────────────────────────────────────────────────
// retire — можно ли выключить старый сервер (ноду #0 с ретранслятором): кто из клиентов и нод
// уже знает новый адрес панели. Клиент узнаёт его из endpoints любого конфига после переноса,
// нода — из ready (core_ok). Ссылки sen:// без устройств ещё никем не открыты: в них старый
// адрес, их выдают заново. nil — панель ничего не принимала (или отметку убрали).
func (m *Migration) retire() any {
since, _ := strconv.ParseInt(os.Getenv("MOVED_IN_AT"), 10, 64)
if since == 0 || m.a.nodes.local {
return nil
}
home := m.a.nodes.home()
var total, fresh, unused int
m.a.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(d.last_seen >= ?), 0) FROM devices d
JOIN master_keys k ON k.id = d.master_id WHERE k.revoked_at IS NULL`, since).Scan(&total, &fresh)
m.a.db.QueryRow(`SELECT COUNT(*) FROM master_keys k WHERE k.revoked_at IS NULL
AND NOT EXISTS (SELECT 1 FROM devices d WHERE d.master_id = k.id)`).Scan(&unused)
nodes := []any{}
if rows, err := m.a.db.Query("SELECT id, name, core_ack FROM nodes WHERE id != 0 AND pub IS NOT NULL ORDER BY id"); err == nil {
for rows.Next() {
var id int64
var name string
var ack sql.NullString
if rows.Scan(&id, &name, &ack) == nil {
nodes = append(nodes, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name},
{Key: "online", Val: m.a.nodes.IsOnline(int(id))}, {Key: "ok", Val: home != "" && ack.String == home}})
}
}
rows.Close()
}
return jsjson.Object{{Key: "since", Val: since}, {Key: "home", Val: orNil(home)},
{Key: "old", Val: jsjson.Object{{Key: "exists", Val: m.a.nodes.Exists(0)}, {Key: "online", Val: m.a.nodes.IsOnline(0)}}},
{Key: "devices", Val: jsjson.Object{{Key: "total", Val: total}, {Key: "fresh", Val: fresh}}},
{Key: "unused_links", Val: unused}, {Key: "nodes", Val: nodes}}
}
func (m *Migration) newCode(c *Ctx) bool {
if why := m.canAccept(); why != "" {
return c.Error(409, why)
}
host, _ := c.Obj().Get2("host").(string)
if h := strings.TrimSpace(os.Getenv("SUB_HOST")); h != "" {
host = h
}
if !reHost.MatchString(host) {
return c.Error(400, "Не удалось определить адрес панели — задайте SUB_HOST")
}
m.mu.Lock()
m.key, m.host, m.expires, m.fails = migrate.NewKey(), host, time.Now().Add(moveCodeTTL), 0
m.mu.Unlock()
logf("migrate: выдан код переноса (действует час)")
return m.status(c)
}
// importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM).
// Ответ с ошибкой — открытым текстом и только пока ничего не тронуто.
func (m *Migration) importHandler(c *Ctx) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.key == nil || time.Now().After(m.expires) {
return c.Error(404, "Новая панель не ждёт переноса: выдайте код заново")
}
box, err := io.ReadAll(http.MaxBytesReader(c.W, c.R.Body, moveMaxBundle))
if err != nil {
return c.Error(413, "Архив слишком большой")
}
plain, err := migrate.OpenRequest(m.key, box)
if err != nil {
if m.fails++; m.fails >= moveMaxFails {
m.key = nil
}
warnf("migrate: архив не расшифрован ip=%s", c.remoteIP())
return c.Error(403, "Код не подходит")
}
key := m.key
m.key = nil // код одноразовый
if why := m.canAccept(); why != "" {
return c.Error(409, why)
}
b, err := migrate.Read(bytes.NewReader(plain))
if err != nil {
return c.Error(400, err.Error())
}
defer b.Close()
host := m.host
plan, err := migrate.NewPlan(b, func(k string) int {
switch k {
case "SUB_PORT":
return m.a.sub.port
case "NODE_PORT":
return m.a.nodes.port
}
return 0
}, m.a.port, host)
if err != nil {
return c.Error(409, err.Error())
}
if ip := net.ParseIP(c.remoteIP()); ip != nil {
plan.TrustRelay(ip.String())
}
// Дальше файлы меняются: база закрывается (WAL сливается в файл до того, как его отложат).
paths := m.paths()
fail := func(err error) bool {
m.state, m.errText = moveImported, "Перенос прерван: "+err.Error()+". Прежние файлы — в *.bak-<время>."
warnf("migrate: %v", err)
return c.Error(500, m.errText)
}
m.a.db.Close()
secret := migrate.NewKey()[:16]
if err := migrate.Install(b, paths); err != nil {
return fail(err)
}
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
return fail(err)
}
pin, err := migrate.CertPin(paths["node_tls.crt"])
if err != nil {
return fail(fmt.Errorf("сертификат хаба: %w", err))
}
if err := envfile.Set(m.envFile, plan.Env); err != nil {
return fail(err)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
m.state = moveImported
logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP())
m.run("restart")
resp := jsjson.MustMarshal(jsjson.Object{{Key: "join", Val: join}})
c.W.Header().Set("Content-Type", "application/octet-stream")
c.W.WriteHeader(200)
c.W.Write(migrate.SealResponse(key, resp))
return false
}
// ── Старая панель ───────────────────────────────────────────────────────────
func (m *Migration) export(c *Ctx) bool {
if why := m.canExport(); why != "" {
return c.Error(409, why)
}
codeStr, _ := c.Obj().Get2("code").(string)
code, err := migrate.ParseCode(codeStr)
if err != nil {
return c.Error(400, err.Error())
}
m.mu.Lock()
if m.state != moveIdle {
m.mu.Unlock()
return c.Error(409, "Перенос уже идёт")
}
m.state, m.errText = moveSending, ""
m.mu.Unlock()
end := func(state, errText string) {
m.mu.Lock()
m.state, m.errText = state, errText
m.mu.Unlock()
m.a.sub.frozen.Store(state != moveIdle)
}
// Пока архив в пути, подписка отвечает «недоступна» (клиенты держат туннель и повторят):
// регистрация или смена ключа сейчас остались бы только в старой базе.
m.a.sub.frozen.Store(true)
var buf bytes.Buffer
if err := migrate.Write(&buf, m.a.db, m.paths(), m.exportMeta()); err != nil {
end(moveIdle, "")
return c.Error(500, "Архив не собран: "+err.Error())
}
logf("migrate: архив %d КБ → %s", buf.Len()/1024, code.URL())
cl := &http.Client{Timeout: moveTimeout, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Post(code.URL(), "application/octet-stream", bytes.NewReader(migrate.SealRequest(code.Key, buf.Bytes())))
if err != nil {
// Не дозвонились — новая точно ничего не получила. Иначе архив мог дойти.
var op *net.OpError
if errors.As(err, &op) && op.Op == "dial" {
end(moveIdle, "")
return c.Error(502, "Новая панель не отвечает: "+err.Error())
}
return m.uncertain(c, end, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != 200 {
end(moveIdle, "")
msg := "Новая панель отказала (" + strconv.Itoa(resp.StatusCode) + ")"
if v, derr := jsjson.Decode(raw); derr == nil {
if e, ok := field(v, "error").(string); ok {
msg = "Новая панель: " + e
}
}
return c.Error(502, msg)
}
var plain []byte
if err == nil {
plain, err = migrate.OpenResponse(code.Key, raw)
}
var join string
if err == nil {
v, derr := jsjson.Decode(plain)
join, _ = field(v, "join").(string)
if derr != nil || !strings.HasPrefix(join, "awgjoin://") {
err = errors.New("ответ без join-строки")
}
}
if err != nil {
return m.uncertain(c, end, err)
}
if err := envfile.Set(m.envFile, [][2]string{{"PANEL_MOVED", "on"}}); err != nil {
return m.uncertain(c, end, err)
}
target := strings.TrimSuffix(code.URL(), "/migrate/v1/import")
m.mu.Lock()
m.target = target
m.mu.Unlock()
end(moveMoved, "")
logf("migrate: новая панель приняла перенос — join, этот сервер станет нодой #0")
m.run("join", join)
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}, {Key: "target", Val: target}})
}
// uncertain — архив ушёл, а подтверждения нет. Подписка остаётся замороженной: если новая
// панель его приняла, вторая живая копия базы разошлась бы с ней.
func (m *Migration) uncertain(c *Ctx, end func(string, string), err error) bool {
msg := "Неизвестно, принят ли перенос (" + err.Error() + "). Откройте новую панель: если там есть сервер #0 — " +
"выполните здесь `awg-ctrl join` со строкой из его карточки; если нет — «Отменить перенос»."
end(moveUncertain, msg)
warnf("migrate: %s", msg)
return c.Error(502, msg)
}
func (m *Migration) cancel(c *Ctx) bool {
m.mu.Lock()
ok := m.state == moveUncertain
if ok {
m.state, m.errText = moveIdle, ""
}
m.mu.Unlock()
if !ok {
return c.Error(409, "Отменять нечего")
}
m.a.sub.frozen.Store(false)
logf("migrate: перенос отменён — подписка снова работает")
return m.status(c)
}
func (m *Migration) exportMeta() jsjson.Object {
name, ip := "VPN", os.Getenv("SERVER_IP")
if r, err := m.a.ctrlLocal("GET", "/health", nil); err == nil {
if s, ok := field(r.Data, "server").(string); ok && s != "" {
name = s
}
if s, ok := field(r.Data, "ip").(string); ok && ip == "" {
ip = s
}
}
return jsjson.Object{
{Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS},
{Key: "node_port", Val: m.a.nodes.port},
}
}
// spawn — `awg-control <args>` отдельным процессом через секунду (ответ успеет уйти). Окружение —
// чистое: CLI читает cli.env заново, а унаследованные значения перекрыли бы новые.
func (m *Migration) spawn(args ...string) {
exe, err := os.Executable()
if err != nil {
warnf("migrate: %v", err)
return
}
var env []string
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
if k == "PATH" || k == "HOME" || k == "LANG" || k == "TZ" || k == "AWG_ROOT" || strings.HasPrefix(k, "LC_") {
env = append(env, kv)
}
}
logFile := filepath.Join(filepath.Dir(m.a.baseDir), "logs", "awg-migrate.log")
go func() {
time.Sleep(time.Second)
cmd := exec.Command(exe, args...)
cmd.Env, cmd.Dir = env, "/"
if f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
cmd.Stdout, cmd.Stderr = f, f
defer f.Close()
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // переживёт эту панель
if err := cmd.Start(); err != nil {
warnf("migrate: awg-control %s: %v", args[0], err)
return
}
cmd.Wait()
}()
}
+174
View File
@@ -0,0 +1,174 @@
package ui
import (
"bufio"
"io"
"net"
"path/filepath"
"strings"
"testing"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testApp(t *testing.T, dir, local string) *App {
t.Helper()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
return a
}
// Перенос панели (`awg-control migrate`): та же ui.db на панели без VPN, старый сервер — нода
// #0. Клиент должен получить тот же конфиг байт в байт, вместе с rev, иначе он пересоздал бы
// туннель, а сервис на macOS — ещё и ключ.
func TestMigratedConfigIsByteIdentical(t *testing.T) {
oldDir, newDir := t.TempDir(), t.TempDir()
old := testApp(t, oldDir, "on")
s := &Sub{a: old, host: "203.0.113.7", port: 40123}
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (1, 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'Нода', 'pub', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0'), (5, 1, 'WG', '10.9.0.9', 'PSK1')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES
(0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0","dns":["1.1.1.1"],"mtu":1376,"gen":"3.1","awg":{"Jc":"4"}}', 1),
(1, '{"name":"Нода","endpoint":"192.0.2.2:47619","server_pub":"S1","gen":"2"}', 1)`,
} {
if _, err := old.db.Exec(q); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
dev := s.deviceByID(old.db, 5)
before := jsjson.MustMarshal(s.buildConfig(dev, nil))
if !strings.Contains(string(before), `"id":0`) || !strings.Contains(string(before), `"id":1`) {
t.Fatalf("в конфиге до переноса нет обоих серверов: %s", before)
}
if _, err := old.db.Exec("VACUUM INTO ?", filepath.Join(newDir, "ui.db")); err != nil {
t.Fatal(err)
}
// Как migrate import: нода #0 ещё не подключалась.
pre := testApp(t, newDir, "off")
if _, err := pre.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, 'VPN', x'00', 9999999999, 1)"); err != nil {
t.Fatal(err)
}
moved := testApp(t, newDir, "off")
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if s2.panelID != s.panelID {
t.Errorf("panel_id: %s → %s — пиры на старом сервере остались бы без хозяина", s.panelID, s2.panelID)
}
after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(moved.db, 5), nil))
if string(before) != string(after) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) || moved.nodes.IsOnline(0) || moved.nodes.DefaultID() != 0 {
t.Errorf("нода #0: exists=%v online=%v default=%d", moved.nodes.Exists(0), moved.nodes.IsOnline(0), moved.nodes.DefaultID())
}
if ids := moved.nodes.IDs(); len(ids) != 2 || ids[0] != 0 || ids[1] != 1 {
t.Errorf("IDs = %v", ids)
}
if r, _ := moved.nodes.CtrlFor(0)("GET", "/health", nil); r.Status != 502 {
t.Errorf("нода #0 не на связи: %d, ожидали 502", r.Status)
}
if l := moved.nodes.List(); len(l) != 2 || l[0].Local || !l[0].Pending {
t.Errorf("List: %+v", l)
}
// Без SUB_HOST адрес подписки — не endpoint ноды #0: это другая машина.
s2.host = ""
if h := s2.subHost(nil); h != "" {
t.Errorf("subHost = %q, ожидали пусто", h)
}
// Свой VPN и нода #0 — один id: такую базу с LOCAL_NODE=on не открываем.
t.Setenv("LOCAL_NODE", "on")
if _, err := newNodes(&App{db: moved.db, baseDir: newDir}); err == nil {
t.Error("LOCAL_NODE=on при ноде #0: ожидали ошибку")
}
}
func TestParseProxyV1(t *testing.T) {
for line, want := range map[string]string{
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "192.0.2.1:51000",
"PROXY TCP6 2001:db8::1 2001:db8::2 51000 40123": "[2001:db8::1]:51000",
"PROXY UNKNOWN": "unknown",
"PROXY UNKNOWN ffff::1 ffff::2 1 2": "unknown",
"PROXY TCP4 2001:db8::1 198.51.100.2 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 70000 1": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 1": "",
"PROXY TCP5 192.0.2.1 198.51.100.2 1 2": "",
"GET / HTTP/1.1": "",
"PROXY TCP4 192.0.2.1 not-an-ip 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "",
} {
addr, ok := parseProxyV1(line)
got := ""
switch {
case ok && addr == nil:
got = "unknown"
case ok:
got = addr.String()
}
if got != want {
t.Errorf("%q → %q, ожидали %q", line, got, want)
}
}
}
// Заголовок PROXY — только от доверенного адреса; соединение с него без заголовка проходит как
// есть, а от остальных заголовок остаётся байтами потока.
func TestProxyListener(t *testing.T) {
try := func(trusted bool, send string) (remote, data string) {
t.Helper()
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer raw.Close()
ln := newProxyListener(raw, map[string]bool{"127.0.0.1": trusted})
cl, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, send)
cl.(*net.TCPConn).CloseWrite()
done := make(chan net.Conn, 1)
go func() { c, _ := ln.Accept(); done <- c }()
select {
case c := <-done:
defer c.Close()
b, _ := io.ReadAll(bufio.NewReader(c))
return c.RemoteAddr().String(), string(b)
case <-time.After(3 * time.Second):
t.Fatal("Accept не вернулся")
}
return "", ""
}
if r, d := try(true, "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"); r != "192.0.2.1:51000" || d != "hello" {
t.Errorf("доверенный с заголовком: %s %q", r, d)
}
if r, d := try(true, "GET / HTTP/1.1\r\n"); !strings.HasPrefix(r, "127.0.0.1:") || d != "GET / HTTP/1.1\r\n" {
t.Errorf("доверенный без заголовка: %s %q", r, d)
}
hdr := "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"
if r, d := try(false, hdr); !strings.HasPrefix(r, "127.0.0.1:") || d != hdr {
t.Errorf("чужой с заголовком: %s %q", r, d)
}
}
+168
View File
@@ -0,0 +1,168 @@
package ui
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
type movePanel struct {
a *App
s *Sub
env string
calls [][]string
}
func newMovePanel(t *testing.T, local, nodePort string) *movePanel {
t.Helper()
dir := t.TempDir()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", nodePort)
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir, port: 34001}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
a.sub = &Sub{a: a, host: "203.0.113.7", port: 40123, locks: map[int]*sync.Mutex{}}
if err := a.sub.initSchema(); err != nil {
t.Fatal(err)
}
p := &movePanel{a: a, s: a.sub, env: filepath.Join(dir, "cli.env")}
a.migration = newMigration(a)
a.migration.envFile = p.env
a.migration.run = func(args ...string) { p.calls = append(p.calls, args) }
return p
}
func (p *movePanel) call(t *testing.T, h handler, body jsjson.Object) (int, jsjson.Object) {
t.Helper()
w := httptest.NewRecorder()
h(&Ctx{W: w, R: httptest.NewRequest("POST", "/", nil), Body: body})
v, _ := jsjson.Decode(w.Body.Bytes())
o, _ := v.(jsjson.Object)
return w.Code, o
}
// Вкладка «Перенос» целиком: код новой панели → архив из старой по HTTP → нода #0, join и
// перезапуск. У клиента конфиг тот же, у старой панели подписка заморожена до join.
func TestMigrateBetweenPanels(t *testing.T) {
old := newMovePanel(t, "on", "")
os.WriteFile(filepath.Join(old.a.baseDir, "sub_sign.key"), []byte("ключ подписи"), 0o600)
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0"}', 1)`,
} {
if _, err := old.a.db.Exec(q); err != nil {
t.Fatal(err)
}
}
before := jsjson.MustMarshal(old.s.buildConfig(old.s.deviceByID(old.a.db, 5), nil))
fresh := newMovePanel(t, "off", "40500")
m := fresh.a.migration
if why := m.canAccept(); why != "" {
t.Fatalf("чистая панель без VPN не принимает: %s", why)
}
if why := old.a.migration.canAccept(); why == "" {
t.Error("панель со своим VPN принимает перенос")
}
if why := fresh.a.migration.canExport(); why == "" {
t.Error("панель без VPN отдаёт перенос")
}
rt := newRouter()
rt.Post("/migrate/v1/import", m.importHandler)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, raw, _, _ := readBody(r, rt.bodyLimit)
rt.routes[0].hs[0](&Ctx{W: w, R: r, Body: body, RawBody: raw})
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port, _ := strconv.Atoi(u.Port())
m.key, m.host, m.expires = migrate.NewKey(), "127.0.0.1", nowFn().Add(moveCodeTTL)
// Чужой ключ: архив не принят, код живёт до пятой неудачи.
wrong := migrate.Code{Host: "127.0.0.1", Port: port, Key: migrate.NewKey()}.String()
if st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: wrong}}); st != 502 || !strings.Contains(o.Get2("error").(string), "Код не подходит") {
t.Fatalf("чужой код: %d %v", st, o)
}
if old.s.frozen.Load() || m.key == nil {
t.Fatal("после отказа подписка заморожена или код сгорел")
}
code := migrate.Code{Host: "127.0.0.1", Port: port, Key: m.key}.String()
st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: code}})
if st != 200 || o.Get2("target") != "http://127.0.0.1:"+u.Port() {
t.Fatalf("export: %d %v", st, o)
}
if !old.s.frozen.Load() {
t.Error("старая подписка не заморожена до join")
}
if len(old.calls) != 1 || old.calls[0][0] != "join" {
t.Fatalf("старая панель: %v", old.calls)
}
j, _ := jsjson.Decode(fromB64url(strings.TrimPrefix(old.calls[0][1], "awgjoin://")))
if field(j, "n") != 0.0 || field(j, "p") != 40500.0 || field(j, "sub") != 40123.0 || field(j, "ui") != 34001.0 {
t.Errorf("join: %s", jsjson.MustMarshal(j))
}
if raw, _ := os.ReadFile(old.env); !strings.Contains(string(raw), "PANEL_MOVED=on") {
t.Errorf("старый cli.env: %q", raw)
}
if len(fresh.calls) != 1 || fresh.calls[0][0] != "restart" || m.state != moveImported || m.key != nil {
t.Errorf("новая панель: %v, state %q", fresh.calls, m.state)
}
raw, _ := os.ReadFile(fresh.env)
for _, want := range []string{"SUB_PORT=40123", "NODE_PORT=40500", "SUB_HOST=127.0.0.1", "RELAY_FROM=127.0.0.1"} {
if !strings.Contains(string(raw), want) {
t.Errorf("новый cli.env без %s: %q", want, raw)
}
}
if k, _ := os.ReadFile(filepath.Join(fresh.a.baseDir, "sub_sign.key")); string(k) != "ключ подписи" {
t.Errorf("ключ подписи: %q", k)
}
// Новая панель после перезапуска: та же база + нода #0 → тот же конфиг.
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(fresh.a.baseDir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
moved := &App{db: db, baseDir: fresh.a.baseDir}
if moved.nodes, err = newNodes(moved); err != nil {
t.Fatal(err)
}
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(db, 5), nil)); string(after) != string(before) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) {
t.Error("нет ноды #0")
}
// Повторный приём: кода уже нет.
if st, _ := old.call(t, func(c *Ctx) bool { old.a.migration.state = moveIdle; return old.a.migration.export(c) },
jsjson.Object{{Key: "code", Val: code}}); st != 502 {
t.Errorf("второй перенос тем же кодом: %d", st)
}
}
+47 -13
View File
@@ -6,7 +6,9 @@ package ui
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает. // своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
// //
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает // 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 ( import (
"context" "context"
@@ -201,6 +203,18 @@ func newNodes(a *App) (*Nodes, error) {
return nil, err 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 return n, nil
} }
@@ -294,15 +308,15 @@ func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp {
// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS. // CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS.
func (n *Nodes) CtrlFor(id int) Ctrl { 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 id == 0 {
if !n.local {
return func(string, string, any) (*CtrlResp, error) { return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
} }
} }
return n.a.ctrlLocal
}
if n.row(int64(id)) == nil {
return func(string, string, any) (*CtrlResp, error) { return func(string, string, any) (*CtrlResp, error) {
return &CtrlResp{404, errObj("Сервер не найден")}, nil return &CtrlResp{404, errObj("Сервер не найден")}, nil
} }
@@ -311,16 +325,16 @@ func (n *Nodes) CtrlFor(id int) Ctrl {
} }
func (n *Nodes) Exists(id int) bool { func (n *Nodes) Exists(id int) bool {
if id == 0 { if id == 0 && n.local {
return n.local return true
} }
return n.row(int64(id)) != nil return n.row(int64(id)) != nil
} }
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда). // IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
func (n *Nodes) IsOnline(id int) bool { func (n *Nodes) IsOnline(id int) bool {
if id == 0 { if id == 0 && n.local {
return n.local return true
} }
n.mu.Lock() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
@@ -385,7 +399,7 @@ func (n *Nodes) DefaultID() int {
func (n *Nodes) refreshHealth(id int64) { func (n *Nodes) refreshHealth(id int64) {
var r *CtrlResp var r *CtrlResp
if id == 0 { if id == 0 && n.local {
r, _ = n.a.ctrlLocal("GET", "/health", nil) r, _ = n.a.ctrlLocal("GET", "/health", nil)
} else { } else {
r = n.rpc(id, "GET", "/health", nil) r = n.rpc(id, "GET", "/health", nil)
@@ -532,7 +546,11 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
n.dropConn(row.ID, old) n.dropConn(row.ID, old)
closeWS(old.ws, 4000, "replaced") 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 := "" first := ""
if joining { if joining {
first = " (первое подключение)" first = " (первое подключение)"
@@ -561,6 +579,12 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
authenticate(m) authenticate(m)
continue continue
} }
if m.Get2("t") == "core_ok" {
if home, ok := m.Get2("core").(string); ok && home == n.home() {
n.a.db.Exec("UPDATE nodes SET core_ack = ? WHERE id = ?", home, nodeID)
}
continue
}
if m.Get2("t") == "res" && isInt(m.Get2("id")) { if m.Get2("t") == "res" && isInt(m.Get2("id")) {
rid := int64(m.Get2("id").(float64)) rid := int64(m.Get2("id").(float64))
conn.mu.Lock() conn.mu.Lock()
@@ -598,7 +622,7 @@ func (n *Nodes) Start() {
srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second, srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second,
ErrorLog: quietLog()} ErrorLog: quietLog()}
go func() { go func() {
ln, err := net.Listen("tcp", srv.Addr) ln, err := listen(n.port)
if err != nil { if err != nil {
warnf("nodes: хаб не запущен: %v", err) warnf("nodes: хаб не запущен: %v", err)
return return
@@ -656,6 +680,16 @@ func (n *Nodes) Start() {
func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil } 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 { 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}, 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)}}) {Key: "pin", Val: b64url.EncodeToString(n.pin)}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}})
@@ -732,7 +766,7 @@ func (s serverInfo) json() jsjson.Object {
func (n *Nodes) idOf(c *Ctx) (int64, bool) { func (n *Nodes) idOf(c *Ctx) (int64, bool) {
f := jsToNumber(c.Params["id"]) 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 { 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)
}
}
+7 -2
View File
@@ -11,7 +11,6 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -113,6 +112,7 @@ type App struct {
sub *Sub sub *Sub
metrics *Metrics metrics *Metrics
monitor *Monitor monitor *Monitor
migration *Migration
} }
// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса // New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса
@@ -185,6 +185,7 @@ func New() (*App, error) {
return nil, err return nil, err
} }
a.monitor = newMonitor(a) a.monitor = newMonitor(a)
a.migration = newMigration(a)
a.nodes.BeforeDelete(a.apiKeysBeforeDelete) a.nodes.BeforeDelete(a.apiKeysBeforeDelete)
a.nodes.AfterDelete(a.apiKeysAfterDelete) a.nodes.AfterDelete(a.apiKeysAfterDelete)
return a, nil return a, nil
@@ -876,6 +877,10 @@ func (a *App) routes() *Router {
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler) app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
app.Use("/ui/monitor", auth) app.Use("/ui/monitor", auth)
app.Mount("/ui/monitor", a.monitor.router()) 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"} { for _, p := range []string{"/api", "/health", "/awg"} {
app.Use(p, auth, a.proxy) app.Use(p, auth, a.proxy)
@@ -937,7 +942,7 @@ func serveFile(w http.ResponseWriter, r *http.Request, f string) {
// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку). // Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку).
func (a *App) Run() error { func (a *App) Run() error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port)) ln, err := listen(a.port)
if err != nil { if err != nil {
return err return err
} }
+14 -3
View File
@@ -29,13 +29,13 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -194,6 +194,8 @@ type Sub struct {
registerLimit, failLimit *limiter registerLimit, failLimit *limiter
frozen atomic.Bool // идёт перенос панели (migrate.go): /sub/v1 отвечает «недоступна»
auditMu sync.Mutex auditMu sync.Mutex
lastAudit *auditReport lastAudit *auditReport
auditSettings auditSettings auditSettings auditSettings
@@ -780,7 +782,8 @@ func (s *Sub) subHost(c *Ctx) string {
if s.host != "" { if s.host != "" {
return 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 p := s.storedProfile(0); p != nil {
if ep, ok := p.Get2("endpoint").(string); ok { if ep, ok := p.Get2("endpoint").(string); ok {
return hostOfEndpoint(ep) return hostOfEndpoint(ep)
@@ -1200,6 +1203,14 @@ func (s *Sub) publicRouter() *Router {
s.send(w, r, 400, subErr("bad_request")) s.send(w, r, 400, subErr("bad_request"))
} }
rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) } 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/register", s.wrapSub(s.register))
rt.Post("/sub/v1/peek", s.wrapSub(s.peek)) rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config)) rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
@@ -1248,7 +1259,7 @@ func (s *Sub) Start() {
return return
} }
srv := &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: s.publicRouter(), ReadHeaderTimeout: 15 * time.Second, ErrorLog: quietLog()} 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 { if err != nil {
warnf("sub: листенер не запущен: %v", err) warnf("sub: листенер не запущен: %v", err)
return return
+227 -32
View File
@@ -210,6 +210,12 @@ func (s *Sub) createMaster(body any, scope []int) (jsjson.Object, error) {
list = append(list, float64(id)) list = append(list, float64(id))
} }
src = list src = list
} else if def := s.defaultServers(); def != nil {
list := []any{}
for _, id := range def {
list = append(list, float64(id))
}
src = list
} else { } else {
src = []any{float64(s.a.nodes.DefaultID())} src = []any{float64(s.a.nodes.DefaultID())}
} }
@@ -281,37 +287,7 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
removed = append(removed, x) removed = append(removed, x)
} }
} }
devices := s.devicesOf(s.a.db, m.ID) if err := s.a.tx(func(tx *sql.Tx) error { return s.moveServersTx(tx, m.ID, added, removed) }); err != nil {
if err := s.a.tx(func(tx *sql.Tx) error {
for _, sid := range removed {
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, 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 (?, ?)", m.ID, 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
}); err != nil {
return nil, err return nil, err
} }
affected = append(append(affected, added...), removed...) affected = append(append(affected, added...), removed...)
@@ -339,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 {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. // rotateMaster — новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
func (s *Sub) rotateMaster(m *masterRow) { func (s *Sub) rotateMaster(m *masterRow) {
secret := make([]byte, 16) secret := make([]byte, 16)
@@ -571,8 +756,18 @@ func (s *Sub) masterKeysRouter() *Router {
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) } master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) }
rt.Get("/", func(c *Ctx) bool { 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): отчёт, внеочередной проход, ручная очистка, настройки. // Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) }) rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) })
rt.Post("/audit", aw(func(c *Ctx) (int, any, error) { rt.Post("/audit", aw(func(c *Ctx) (int, any, error) {