From eb19cacf93751df6550b15b9ec2d19eaa8017158 Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Wed, 21 Oct 2026 15:59:33 +0300 Subject: [PATCH] Move the panel to a server without VPN; servers for all master keys at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 14 +- awg-ui/mock-server.ts | 66 ++- awg-ui/src/components/icons.tsx | 7 + .../src/tabs/masterkeys/BulkServersModal.tsx | 125 +++++ awg-ui/src/tabs/masterkeys/index.tsx | 22 +- awg-ui/src/tabs/masterkeys/masterkeys.css | 30 ++ awg-ui/src/tabs/migrate/index.tsx | 229 +++++++++ awg-ui/src/tabs/migrate/metadata.json | 11 + awg-ui/src/tabs/migrate/migrate.css | 26 + cmd/awg-control/main.go | 10 +- install.sh | 5 +- internal/agent/agent.go | 113 ++++- internal/agent/agent_test.go | 83 ++++ internal/cli/cli.go | 69 ++- internal/cli/cmds.go | 45 +- internal/cli/migrate.go | 292 +++++++++++ internal/cli/migrate_test.go | 79 +++ internal/envfile/envfile.go | 43 ++ internal/migrate/migrate.go | 382 +++++++++++++++ internal/migrate/migrate_test.go | 176 +++++++ internal/migrate/seal.go | 115 +++++ internal/relay/relay.go | 159 ++++++ internal/relay/relay_test.go | 102 ++++ internal/ui/bulk_servers_test.go | 104 ++++ internal/ui/migrate.go | 454 ++++++++++++++++++ internal/ui/migrate_test.go | 174 +++++++ internal/ui/migrate_ui_test.go | 168 +++++++ internal/ui/nodes.go | 62 ++- internal/ui/proxyproto.go | 149 ++++++ internal/ui/retire_test.go | 102 ++++ internal/ui/server.go | 17 +- internal/ui/sub.go | 17 +- internal/ui/sub_admin.go | 259 ++++++++-- 33 files changed, 3603 insertions(+), 106 deletions(-) create mode 100644 awg-ui/src/tabs/masterkeys/BulkServersModal.tsx create mode 100644 awg-ui/src/tabs/migrate/index.tsx create mode 100644 awg-ui/src/tabs/migrate/metadata.json create mode 100644 awg-ui/src/tabs/migrate/migrate.css create mode 100644 internal/cli/migrate.go create mode 100644 internal/cli/migrate_test.go create mode 100644 internal/envfile/envfile.go create mode 100644 internal/migrate/migrate.go create mode 100644 internal/migrate/migrate_test.go create mode 100644 internal/migrate/seal.go create mode 100644 internal/relay/relay.go create mode 100644 internal/relay/relay_test.go create mode 100644 internal/ui/bulk_servers_test.go create mode 100644 internal/ui/migrate.go create mode 100644 internal/ui/migrate_test.go create mode 100644 internal/ui/migrate_ui_test.go create mode 100644 internal/ui/proxyproto.go create mode 100644 internal/ui/retire_test.go diff --git a/README.md b/README.md index 98609bf..19a33d1 100644 --- a/README.md +++ b/README.md @@ -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` | -| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` | +| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }], defaults }`; `:id` в путях — id или `uuid`; `defaults` — серверы новых ключей или `null` | +| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201`; без `servers` — `defaults`, а нет их — сервер по умолчанию | +| `POST /ui/masterkeys/servers` | «Серверы для всех»: `{ add?, remove?, defaults?, dry_run? }` — `add` появляются у всех живых ключей, `remove` снимаются у всех, прочие серверы ключей не трогаются; `defaults` (`null` — сбросить) — набор новых ключей. → `{ changed, total, hidden: [{ id, label, keys }] }`, `hidden` — ключи API, которые перестанут видеть столько мастер-ключей (сервер вне их области). `409`, если у ключа не останется серверов | | `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` | | `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` | | `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком | @@ -211,7 +212,14 @@ base64url-декодировать, отбросить первые 4 байта ## Управление ```bash -awg-ctrl +awg-ctrl # меню +awg-ctrl start|stop|restart|status +awg-ctrl credentials [user|pass] +awg-ctrl join '' [--no-panel] / unjoin +awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес +awg-ctrl migrate export [файл] # перенос панели на отдельный сервер без VPN +awg-ctrl migrate import <архив> # (порядок — docs/node-protocol.md, «Перенос панели») +awg-ctrl migrate cancel ``` Через systemd (автозапуск на загрузке): diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index a0f7c33..385a674 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -53,7 +53,9 @@ interface Dev { id: number; device_id: string; device_name: string; platform: st interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] } let mkId = 1, devId = 1; const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) }); -const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }]; +const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }, + { id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0] }, + { id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }]; const signPub = crypto.randomBytes(32); const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) }); // Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась). @@ -121,6 +123,50 @@ app.get("/ui/monitor", (_q, r) => { for (const action of ["install", "remove"] as const) { app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); }); } +// Перенос панели (internal/ui/migrate.go). Мок — standalone: переносить можно, принимать нельзя; +// MOCK_ROLE=core — наоборот (код «принимается» через 10 с, ещё через 10 с — «перезапуск»); +// MOCK_ROLE=retire — панель уже приняла перенос: готовность к отключению старого сервера. +const MOCK_CORE = process.env.MOCK_ROLE === "core" || process.env.MOCK_ROLE === "retire"; +let mv: { state: string; error: string | null; target: string | null; code: { code: string; at: number } | null; movedAt: number } = + { state: "", error: null, target: null, code: null, movedAt: process.env.MOCK_ROLE === "retire" ? Date.now() : 0 }; +const mvRetire = () => { + if (!mv.movedAt) return null; + const t = (Date.now() - mv.movedAt) / 1000; // клиенты и ноды «узнают» адрес со временем + return { since: Math.floor(mv.movedAt / 1000), home: "198.51.100.20:40500", + old: { exists: true, online: true }, + devices: { total: 12, fresh: Math.min(12, 7 + Math.floor(t / 15)) }, unused_links: 1, + nodes: [{ id: 1, name: "Frankfurt", online: true, ok: true }, { id: 2, name: "Tokyo", online: false, ok: false }, + { id: 4, name: "Amsterdam", online: true, ok: t > 20 }] }; +}; +const mvView = () => { + if (mv.code && Date.now() - mv.code.at > 10000) mv = { ...mv, state: "imported", code: null, movedAt: Date.now() }; + if (mv.state === "imported" && Date.now() - mv.movedAt > 10000) mv = { ...mv, state: "" }; + return { state: mv.state, error: mv.error, target: mv.target, + code: mv.code && { code: mv.code.code, expires_in: 3600 - Math.floor((Date.now() - mv.code.at) / 1000) }, + ...(mv.state === "imported" ? {} : { + export: MOCK_CORE ? "У этой панели нет своего VPN: переносить её как сервер-ноду нечего" : null, + accept: !MOCK_CORE ? "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»" + : mv.movedAt ? "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую" : null, + retire: mvRetire() }) }; +}; +app.get("/ui/migrate", (_q, r) => r.json(mvView())); +app.post("/ui/migrate/code", (q, r) => { + if (!MOCK_CORE) return r.status(409).json({ error: "У этой панели свой VPN" }); + mv.code = { code: "awgmove://" + Buffer.from(JSON.stringify({ v: 1, h: q.body?.host ?? "127.0.0.1", p: 8080, k: "mock" })).toString("base64url"), at: Date.now() }; + r.json(mvView()); +}); +app.delete("/ui/migrate/code", (_q, r) => { mv.code = null; r.json(mvView()); }); +app.post("/ui/migrate/export", (q, r) => { + const code = String(q.body?.code ?? ""); + setTimeout(() => { + if (!code.startsWith("awgmove://ey")) return r.status(400).json({ error: "код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели" }); + mv = { ...mv, state: "moved", target: "http://198.51.100.20:31337" }; + r.json({ ok: true, target: mv.target }); + }, 1500); +}); +app.post("/ui/migrate/cancel", (_q, r) => { mv = { ...mv, state: "", error: null }; r.json(mvView()); }); +app.delete("/ui/migrate/retire", (_q, r) => { mv.movedAt = 0; r.json(mvView()); }); + app.get("/ui/monitor/overview", (q, r) => { const R: Record = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "7d": [604800, 1800], "30d": [2592000, 7200] }; const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"]; @@ -248,11 +294,25 @@ app.post("/ui/masterkeys/audit/clean", (_q, r) => { mockGhost = false; r.json(mockAudit()); }); app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); }); -app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) })); +let mkDefaults: number[] | null = null; +app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut), defaults: mkDefaults })); +// «Серверы для всех» (sub_admin.go bulkServers): add — у всех, remove — ни у кого. +app.post("/ui/masterkeys/servers", (q, r) => { + const add: number[] = q.body.add ?? [], remove: number[] = q.body.remove ?? []; + if (add.some(x => remove.includes(x))) return r.status(400).json({ error: "Сервер не может быть и у всех, и ни у кого" }); + const next = masters.map(m => ({ m, s: [...new Set([...m.servers.filter(x => !remove.includes(x)), ...add])].sort() })); + const empty = next.filter(x => !x.s.length).length; + if (empty) return r.status(409).json({ error: `У ${empty} ключей не останется ни одного сервера — оставьте им хотя бы один` }); + const changed = next.filter(x => x.s.join() !== [...x.m.servers].sort().join()); + const within = (l: number[], sc: number[]) => l.every(x => sc.includes(x)); + const hidden = apiKeys.map(k => ({ id: k.id, label: k.label, keys: changed.filter(x => within(x.m.servers, k.servers) && !within(x.s, k.servers)).length })).filter(h => h.keys); + if (!q.body.dry_run) { changed.forEach(x => { x.m.servers = x.s; }); if ("defaults" in q.body) mkDefaults = q.body.defaults; } + r.json({ changed: changed.length, total: masters.length, hidden }); +}); app.post("/ui/masterkeys", (q, r) => { const limit = q.body.device_limit ?? 3; if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" }); - const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] }; + const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? mkDefaults ?? [0] }; masters.push(m); r.status(201).json(mkOut(m)); }); app.patch("/ui/masterkeys/:id", (q, r) => { diff --git a/awg-ui/src/components/icons.tsx b/awg-ui/src/components/icons.tsx index 6260fc4..1882371 100644 --- a/awg-ui/src/components/icons.tsx +++ b/awg-ui/src/components/icons.tsx @@ -102,9 +102,16 @@ export const IcoPulse = () => ( ); +export const IcoMove = () => ( + + + +); + export const ICONS: Record JSX.Element> = { users: IcoUsers, key: IcoKey, shield: IcoShield, pulse: IcoPulse, + move: IcoMove, }; \ No newline at end of file diff --git a/awg-ui/src/tabs/masterkeys/BulkServersModal.tsx b/awg-ui/src/tabs/masterkeys/BulkServersModal.tsx new file mode 100644 index 0000000..107e3f8 --- /dev/null +++ b/awg-ui/src/tabs/masterkeys/BulkServersModal.tsx @@ -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>(() => Object.fromEntries(nodes.map(n => [n.id, initial(n.id)]))); + const [forNew, setForNew] = useState(true); + const [preview, setPreview] = useState(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) => ( + + ); + + return ( +
+
e.stopPropagation()}> +

Серверы для всех ключей

+

+ Применится ко всем мастер-ключам сразу ({total}). Пользователям ничего делать не нужно: приложение получит + новый набор серверов при следующем обновлении подписки, ссылки и ключи остаются прежними. +

+
+ {nodes.map(n => { + const c = count(n.id), partial = c > 0 && c < total; + return ( +
+ + + + + {n.health?.server || n.name} + + {n.health?.ip && {n.health.ip}} + {n.health?.gen && {genLabel(n.health.gen)}} + сейчас у {c} из {total} + + + + {option(n.id, 'all', 'У всех')} + {partial && option(n.id, 'keep', 'Как есть')} + {option(n.id, 'none', 'Ни у кого')} + +
+ ); + })} +
+ + {preview && ( +

Изменится ключей: {preview.changed} из {preview.total}.

+ )} + {preview?.hidden.map(h => ( +

+ Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области. + Расширьте его область на вкладке «API-ключи», если он ими управляет. +

+ ))} + {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/awg-ui/src/tabs/masterkeys/index.tsx b/awg-ui/src/tabs/masterkeys/index.tsx index c15690b..72cca50 100644 --- a/awg-ui/src/tabs/masterkeys/index.tsx +++ b/awg-ui/src/tabs/masterkeys/index.tsx @@ -1,13 +1,14 @@ // Copyright (c) 2026 Ivan Vasilev // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. -import { Fragment, useState, useEffect, useCallback, useRef } from 'react'; +import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import QRCode from 'qrcode'; import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared'; import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons'; import ServerPicker, { serverName } from '../../components/ServerPicker'; import PriorityChip from '../../components/PriorityChip'; import TorrentChip from '../../components/TorrentChip'; +import BulkServersModal from './BulkServersModal'; import './masterkeys.css'; // Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка @@ -66,6 +67,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается const creatingRef = useRef(false); const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null); + const [defaults, setDefaults] = useState(null); // серверы новых ключей из «Серверы для всех» + const [bulk, setBulk] = useState(false); const load = useCallback(async () => { const seq = ++loadSeq.current; @@ -73,7 +76,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { const d = await apiFetch('GET', '/ui/masterkeys', token); const list: MasterKey[] = d.keys ?? []; 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(() => {}); // Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных. const live = list.filter(k => !k.deleting); @@ -106,15 +109,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { if (!label.trim() || creatingRef.current) return; creatingRef.current = true; setCreating(true); try { await act(async () => { - // Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа. + // Новый ключ — с серверами из «Серверы для всех» (их подставит панель), а не задано — со всеми, + // которые уже подключались; набор потом правится в настройках ключа. const ready = nodes.filter(n => !n.pending).map(n => n.id); await apiFetch('POST', '/ui/masterkeys', token, { - label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}), + label: label.trim(), device_limit: Number(limit), ...(!defaults && ready.length ? { servers: ready } : {}), }); setLabel(''); }, 'Мастер-ключ создан'); } 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]); @@ -403,6 +408,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
+
@@ -518,6 +525,11 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { )} + {bulk && ( + setBulk(false)} + onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} /> + )} + {modal && (
setModal(null)}>
e.stopPropagation()}> diff --git a/awg-ui/src/tabs/masterkeys/masterkeys.css b/awg-ui/src/tabs/masterkeys/masterkeys.css index 0f6790c..2791784 100644 --- a/awg-ui/src/tabs/masterkeys/masterkeys.css +++ b/awg-ui/src/tabs/masterkeys/masterkeys.css @@ -279,3 +279,33 @@ LICENSE file in the root directory of this source tree. .mk-free { padding: 12px; font-size: 13px; } } + +/* «Серверы для всех» (BulkServersModal) */ +.bs-modal { max-width: 620px; } +.bs-list { display: flex; flex-direction: column; gap: 8px; } +.bs-row { + display: flex; align-items: center; gap: 10px; + background: var(--surface-container); border-radius: 12px; padding: 8px 8px 8px 14px; + font-size: 13px; color: var(--on-surface); +} +.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } +.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--neutral-text); } +.bs-sub .server-pick-ip { margin-left: 0; } +.bs-opts { display: flex; gap: 4px; flex-shrink: 0; } +.bs-opt { + min-height: 36px; padding: 0 12px; + font: inherit; font-size: 13px; color: var(--on-surface); + background: var(--surface-bright); + border: 1px solid var(--outline); border-radius: var(--r-pill); + cursor: pointer; white-space: nowrap; +} +.bs-opt:hover { background: var(--state-primary); } +.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; } +.bs-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--on-surface); cursor: pointer; } +.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; } +@media (max-width: 640px) { + .bs-row { flex-wrap: wrap; } + .bs-opts { width: 100%; } + .bs-opt { flex: 1; } +} +.bs-modal .btn:disabled { opacity: .5; cursor: default; } diff --git a/awg-ui/src/tabs/migrate/index.tsx b/awg-ui/src/tabs/migrate/index.tsx new file mode 100644 index 0000000..ac6964b --- /dev/null +++ b/awg-ui/src/tabs/migrate/index.tsx @@ -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(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 ( +
+ Панель перенесена +

+ Новая панель: {info.target}. Через несколько секунд этот сервер станет её + нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать, + а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели. +

+ {info.target && Открыть новую панель} +
+ ); + + if (info.state === 'imported') return ( +
+ + {info.error ? 'Приём прерван' : 'Панель принята'} +

+ {info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'} +

+
+ ); + + 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) => ( + {text} + ); + return ( +
+

Отключение старого сервера

+

+ {r.old.exists + ? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его + можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними. + : <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.} +

+
+
+ {chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)} + устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'} +
+ {r.nodes.map(n => ( +
+ {chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)} + нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'} +
+ ))} + {r.unused_links > 0 && ( +
+ {chip(false, String(r.unused_links), true)} + мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на + вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же +
+ )} +
+ {!r.home &&

Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.

} +
    +
  1. Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.
  2. +
  3. Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.
  4. +
  5. Удалите сервер «#0» и выключите старую машину.
  6. +
+ {!r.old.exists && ( + + )} +
+ ); + })(); + + const canExport = info.export === null, canAccept = info.accept === null; + const exportBox = ( +
+

Перенести эту панель на другой сервер

+ {canExport ? ( + <> +

+ Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели. + Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними. +

+
    +
  1. На новом сервере установите Forgetting с ролью «только панель».
  2. +
  3. Там на вкладке «Перенос» нажмите «Получить код».
  4. +
  5. Вставьте код сюда и нажмите «Перенести».
  6. +
+ {info.state === 'uncertain' ? ( + <> +

{info.error}

+ + + ) : ( +
+ setCode(e.target.value)} /> + +
+ )} + + ) :

{info.export}

} +
+ ); + const acceptBox = ( +
+

Принять панель с другого сервера

+ {canAccept ? ( + info.code ? ( + <> +

+ Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует + ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода. +

+ {info.code.code} +
+ + +
+

Ждём архив от старой панели…

+ + ) : ( + <> +

+ Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней + нодой #0. Логин и пароль останутся этой панели. +

+ + + ) + ) :

{info.accept}

} +
+ ); + + return ( + <> + {/* Доступное действие — первым. */} + {retireBox} + {canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]} + {error &&

{error}

} + + ); +} diff --git a/awg-ui/src/tabs/migrate/metadata.json b/awg-ui/src/tabs/migrate/metadata.json new file mode 100644 index 0000000..15469cf --- /dev/null +++ b/awg-ui/src/tabs/migrate/metadata.json @@ -0,0 +1,11 @@ +{ + "id": "migrate", + "label": "Перенос", + "icon": "move", + "group": "server", + "order": 1.9, + "chrome": { + "serverBar": false, + "restart": false + } +} diff --git a/awg-ui/src/tabs/migrate/migrate.css b/awg-ui/src/tabs/migrate/migrate.css new file mode 100644 index 0000000..20999a3 --- /dev/null +++ b/awg-ui/src/tabs/migrate/migrate.css @@ -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; } diff --git a/cmd/awg-control/main.go b/cmd/awg-control/main.go index 107965f..b8c07c3 100644 --- a/cmd/awg-control/main.go +++ b/cmd/awg-control/main.go @@ -1,4 +1,4 @@ -// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent и команды оператора. +// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent / relay и команды оператора. package main import ( @@ -11,6 +11,7 @@ import ( "github.com/maeneko/forgetting/internal/agent" "github.com/maeneko/forgetting/internal/cli" "github.com/maeneko/forgetting/internal/ctrl" + "github.com/maeneko/forgetting/internal/relay" "github.com/maeneko/forgetting/internal/ui" ) @@ -60,6 +61,13 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() a.Run(ctx) + case "relay": + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := relay.Main(ctx); err != nil && ctx.Err() == nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } default: cli.Main(os.Args[1:]) } diff --git a/install.sh b/install.sh index 1cb8584..658bede 100644 --- a/install.sh +++ b/install.sh @@ -702,8 +702,9 @@ parse_join() { CORE_PIN=$(sed -nE 's/.*"pin":"([A-Za-z0-9_-]{43})".*/\1/p' <<< "$json") NODE_ID=$(sed -nE 's/.*"n":([0-9]+)[,}].*/\1/p' <<< "$json") JOIN_SECRET=$(sed -nE 's/.*"s":"([A-Za-z0-9_-]{22})".*/\1/p' <<< "$json") - [[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" ]] \ - && (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID > 0 )) + # NODE_ID 0 — сервер, с которого переехала панель (awg-ctrl migrate import). + [[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" && -n "$NODE_ID" ]] \ + && (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID >= 0 )) } # SHA-256 от SPKI сертификата панели, base64url без «=» — в том же виде, что pin в строке. diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9d36886..3391c78 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -17,6 +17,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -29,6 +30,7 @@ import ( "github.com/coder/websocket" + "github.com/maeneko/forgetting/internal/envfile" "github.com/maeneko/forgetting/internal/iauth" "github.com/maeneko/forgetting/internal/jsjson" ) @@ -47,6 +49,7 @@ const ( type Config struct { CoreHost string CorePort int + CoreEndpoint string // «host:port», который core сообщил сам (после переезда панели); пробуется первым CorePin string // SHA-256 SPKI сертификата core, base64url NodeID int Ctrl string // http://127.0.0.1:PORT @@ -77,6 +80,8 @@ func envFromFile(file, key string) string { func ConfigFromEnv() (Config, error) { cwd, _ := os.Getwd() atoi := func(k string) int { n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(k))); return n } + // Нода №0 — сервер, с которого переехала панель: 0 здесь — значение, а не «не задано». + nodeID, idErr := strconv.Atoi(strings.TrimSpace(os.Getenv("NODE_ID"))) or := func(k, def string) string { if v, ok := os.LookupEnv(k); ok { return v @@ -84,21 +89,22 @@ func ConfigFromEnv() (Config, error) { return def } c := Config{ - CoreHost: os.Getenv("CORE_HOST"), - CorePort: atoi("CORE_PORT"), - CorePin: os.Getenv("CORE_PIN"), - NodeID: atoi("NODE_ID"), - Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"), - NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")), - InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"), - CLIEnvFile: os.Getenv("CLI_ENV_FILE"), - JoinSecret: os.Getenv("JOIN_SECRET"), - Version: os.Getenv("VERSION"), + CoreHost: os.Getenv("CORE_HOST"), + CorePort: atoi("CORE_PORT"), + CorePin: os.Getenv("CORE_PIN"), + CoreEndpoint: os.Getenv("CORE_ENDPOINT"), + NodeID: nodeID, + Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"), + NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")), + InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"), + CLIEnvFile: os.Getenv("CLI_ENV_FILE"), + JoinSecret: os.Getenv("JOIN_SECRET"), + Version: os.Getenv("VERSION"), } if c.Version == "" { c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION") } - if c.CoreHost == "" || c.CorePort == 0 || c.CorePin == "" || c.NodeID == 0 { + if c.CoreHost == "" || c.CorePort == 0 || c.CorePin == "" || idErr != nil || c.NodeID < 0 { return c, errors.New("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`") } return c, nil @@ -112,6 +118,8 @@ type Agent struct { mu sync.Mutex joinSecret string + addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT) + cur int http *http.Client } @@ -152,6 +160,7 @@ func New(c Config) (*Agent, error) { // awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен. http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout}, } + a.setAddrs(c.CoreEndpoint) if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil { a.internalKey = ik } else { @@ -306,6 +315,71 @@ func (a *Agent) forgetJoinSecret() { os.WriteFile(a.c.CLIEnvFile, []byte(strings.Join(kept, "\n")), 0o600) } +// setAddrs — сначала адрес, который сообщил core, потом адрес из join: как endpoints у клиента +// sen://. Чужой сервер по нему не подменить — доверие даёт pin, а не адрес. +func (a *Agent) setAddrs(learned string) { + a.mu.Lock() + defer a.mu.Unlock() + a.addrs = nil + for _, s := range []string{learned, hostPort(a.c.CoreHost, a.c.CorePort)} { + if s != "" && (len(a.addrs) == 0 || a.addrs[0] != s) { + a.addrs = append(a.addrs, s) + } + } + a.cur = 0 +} + +func (a *Agent) addr() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.addrs[a.cur%len(a.addrs)] +} + +// next — не дозвонились (или core не принял): следующий адрес. +func (a *Agent) next() { + a.mu.Lock() + a.cur = (a.cur + 1) % len(a.addrs) + a.mu.Unlock() +} + +// learnCore — core сообщил свой адрес (панель переехала, старый сервер скоро выключат): +// запоминаем в cli.env, чтобы после перезапуска звонить сразу туда. false — не запомнили. +func (a *Agent) learnCore(addr string) bool { + host, port, err := net.SplitHostPort(addr) + if p, perr := strconv.Atoi(port); err != nil || perr != nil || p <= 0 || p > 65535 || !reCoreHost.MatchString(host) { + return false + } + addr = hostPort(host, mustAtoi(port)) + if addr == a.c.CoreEndpoint { + return true + } + if a.c.CLIEnvFile == "" { + return false + } + if err := envfile.Set(a.c.CLIEnvFile, [][2]string{{"CORE_ENDPOINT", addr}}); err != nil { + a.c.Errorf("agent: адрес core не сохранён: %v", err) + return false + } + a.c.CoreEndpoint = addr + a.mu.Lock() + cur := a.addrs[a.cur%len(a.addrs)] + a.mu.Unlock() + a.setAddrs(addr) + a.mu.Lock() + for i, s := range a.addrs { // текущее соединение не рвём: адрес пригодится при следующем + if s == cur { + a.cur = i + } + } + a.mu.Unlock() + a.c.Logf("agent: core теперь доступен и по %s", addr) + return true +} + +var reCoreHost = regexp.MustCompile(`^[A-Za-z0-9.:-]{1,253}$`) + +func mustAtoi(s string) int { n, _ := strconv.Atoi(s); return n } + func (a *Agent) secret() string { a.mu.Lock() defer a.mu.Unlock() @@ -337,7 +411,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout} dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout) - ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", hostPort(a.c.CoreHost, a.c.CorePort)), &websocket.DialOptions{ + ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{ HTTPClient: &http.Client{Transport: tr}, CompressionMode: websocket.CompressionDisabled, OnPingReceived: func(context.Context, []byte) bool { armDead(); return true }, @@ -405,7 +479,12 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo a.forgetJoinSecret() a.c.Logf("agent: нода привязана к core") } - a.c.Logf("agent: подключено к core") + a.c.Logf("agent: подключено к core (%s)", a.addr()) + // Подтверждение — только когда адрес уже на диске: по нему панель решает, что + // старый сервер можно выключать. + if core, ok := m.Get2("core").(string); ok && a.learnCore(core) { + send(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: core}}) + } case t == "req" && ready: id, ok := m.Get2("id").(float64) if !ok || id != float64(int64(id)) { @@ -429,10 +508,14 @@ func hostPort(host string, port int) string { // Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута. func (a *Agent) Run(ctx context.Context) error { - a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, hostPort(a.c.CoreHost, a.c.CorePort)) + a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr()) backoff := time.Second for { - authFailed := a.connectOnce(ctx, func() { backoff = time.Second }) + ready := false + authFailed := a.connectOnce(ctx, func() { backoff, ready = time.Second, true }) + if !ready { + a.next() + } wait := backoff if authFailed { wait = maxBackoff diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index e92aa1b..9bd780b 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -268,3 +268,86 @@ func TestNodeKeyPersists(t *testing.T) { t.Fatalf("node.key mode %v", st.Mode().Perm()) } } + +// Нода #0 — сервер, с которого переехала панель: 0 — значение, а не «не задан». +func TestConfigNodeZero(t *testing.T) { + t.Setenv("CORE_HOST", "203.0.113.7") + t.Setenv("CORE_PORT", "40500") + t.Setenv("CORE_PIN", "pin") + for id, ok := range map[string]bool{"0": true, "3": true, "": false, "-1": false, "x": false} { + t.Setenv("NODE_ID", id) + c, err := ConfigFromEnv() + if (err == nil) != ok { + t.Errorf("NODE_ID=%q: %v", id, err) + } + if ok && strconv.Itoa(c.NodeID) != id { + t.Errorf("NODE_ID=%q → %d", id, c.NodeID) + } + } +} + +// Панель переехала: core сообщает свой адрес в ready, агент записывает его в cli.env, +// подтверждает (core_ok) и с тех пор звонит туда первым, а адрес из join — запасной. +func TestLearnsCoreAddress(t *testing.T) { + c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) { + send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}}) + read() + send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}, {Key: "core", Val: "198.51.100.9:40500"}}) + read() + ws.Close(websocket.StatusNormalClosure, "") + }) + a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1") + host, port := c.hostPort() + join := hostPort(host, port) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + a.connectOnce(ctx, nil) + + <-c.frames // auth + if ok := <-c.frames; string(jsjson.MustMarshal(ok)) != `{"t":"core_ok","core":"198.51.100.9:40500"}` { + t.Errorf("подтверждение: %s", jsjson.MustMarshal(ok)) + } + if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "CORE_ENDPOINT=198.51.100.9:40500") { + t.Errorf("cli.env: %q", raw) + } + if strings.Join(a.addrs, " ") != "198.51.100.9:40500 "+join || a.addr() != join { + t.Errorf("адреса %v, текущий %s", a.addrs, a.addr()) + } +} + +func TestBadCoreAddressIgnored(t *testing.T) { + a := &Agent{c: Config{CoreHost: "203.0.113.1", CorePort: 40500, CLIEnvFile: filepath.Join(t.TempDir(), "cli.env"), Logf: t.Logf, Errorf: t.Logf}} + a.setAddrs("") + for _, bad := range []string{"", "x", "host:0", "host:99999", "a b:1", "host:1/x", "[::1]:x"} { + if a.learnCore(bad) { + t.Errorf("принят адрес %q", bad) + } + } + if !a.learnCore("[2001:db8::1]:443") || a.addrs[0] != "[2001:db8::1]:443" { + t.Errorf("IPv6: %v", a.addrs) + } +} + +// Записанный адрес не отвечает (старый сервер выключен или новый ещё не открыт) — следующая +// попытка идёт на адрес из join. +func TestFallsBackToJoinAddress(t *testing.T) { + connected := make(chan struct{}, 1) + c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) { + send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}}) + read() + send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}}) + connected <- struct{}{} + <-ctx.Done() + }) + a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1") + a.c.CoreEndpoint = "127.0.0.1:1" + a.setAddrs(a.c.CoreEndpoint) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + go a.Run(ctx) + select { + case <-connected: + case <-ctx.Done(): + t.Fatal("агент не перешёл на адрес из join") + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7a0d264..8b4d125 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -17,6 +17,7 @@ import ( "syscall" "time" + "github.com/maeneko/forgetting/internal/envfile" "github.com/maeneko/forgetting/internal/jsjson" ) @@ -181,6 +182,12 @@ func New() *CLI { {"SUB_HOST", env("SUB_HOST", "")}, // Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN. {"NODE_PORT", env("NODE_PORT", "")}, + // IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY. + {"RELAY_FROM", env("RELAY_FROM", "")}, + // Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес. + {"MOVED_IN_AT", env("MOVED_IN_AT", "")}, + // Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM. + {"CLI_ENV_FILE", cliEnv}, // Часовой аудит пиров на всех серверах — только у core. {"AUDIT", audit}, {"METRICS_PEERS", env("METRICS_PEERS", "off")}, @@ -196,6 +203,7 @@ func New() *CLI { env: [][2]string{ {"CORE_HOST", env("CORE_HOST", "")}, {"CORE_PORT", env("CORE_PORT", "")}, + {"CORE_ENDPOINT", env("CORE_ENDPOINT", "")}, {"CORE_PIN", env("CORE_PIN", "")}, {"NODE_ID", env("NODE_ID", "")}, {"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")}, @@ -206,6 +214,12 @@ func New() *CLI { }, pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"), }, + // Ретранслятор на сервере, с которого переехала панель: прежние порты → новая панель. + "relay": { + name: "relay", label: "awg-relay", cwd: c.root, + env: [][2]string{{"RELAY", env("RELAY", "")}}, + pidFile: "/tmp/awg-relay.pid", logFile: filepath.Join(c.logs, "awg-relay.log"), + }, } // Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с @@ -224,9 +238,29 @@ func New() *CLI { default: c.all = []string{"awgctrl", "ui"} } + // Панель переехала (`migrate export`): здесь её больше не запускаем — вторая копия ui.db + // разошлась бы с перенесённой. + if c.panelMoved() { + c.all = without(c.all, "ui") + } + if os.Getenv("RELAY") != "" { + c.all = append(c.all, "relay") + } return c } +func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" } + +func without(s []string, v string) []string { + var out []string + for _, x := range s { + if x != v { + out = append(out, x) + } + } + return out +} + func (c *CLI) envOf(name string) string { for _, kv := range c.svc[name].env { if kv[0] == "PORT" { @@ -285,6 +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 { return true } + if s.legacy == "" { + return false + } // tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent. return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd)) } @@ -476,10 +513,14 @@ func (c *CLI) startAll() { } return } + // Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN. + if contains(c.all, "relay") { + c.start("relay") + } c.start("awgctrl") var rest, labels []string for _, n := range c.all { - if n != "awgctrl" { + if n != "awgctrl" && n != "relay" { rest = append(rest, n) labels = append(labels, c.svc[n].label) } @@ -515,23 +556,9 @@ func contains(s []string, v string) bool { // ── cli.env ───────────────────────────────────────────────────────────────── -func updateEnvVar(content, key, value string) string { - re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`) - if loc := re.FindStringIndex(content); loc != nil { - return content[:loc[0]] + key + "=" + value + content[loc[1]:] - } - return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n" -} +func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) } -func removeEnvVar(content, key string) string { - var kept []string - for _, l := range strings.Split(content, "\n") { - if !strings.HasPrefix(l, key+"=") { - kept = append(kept, l) - } - } - return strings.Join(kept, "\n") -} +func removeEnvVar(content, key string) string { return envfile.Remove(content, key) } func (c *CLI) readEnvFile() string { raw, _ := os.ReadFile(c.envFile) @@ -568,10 +595,10 @@ func (c *CLI) confirmYes(q string) bool { // ── Точка входа ───────────────────────────────────────────────────────────── -const usage = "Usage: cli [--no-panel]|unjoin|peers [drop <область>]|monitor >" +const usage = "Usage: cli [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor |migrate |cancel>>" // Commands — команды оператора; main передаёт сюда всё, что не роль сервиса. -var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "peers", "monitor"} +var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"} // Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню. func Main(args []string) { @@ -608,6 +635,10 @@ func Main(args []string) { c.joinCore(sub, hasSub, a2, hasA2) case "unjoin": c.unjoinCore() + case "rehome": + c.rehome(sub, hasSub) + case "migrate": + c.migrateCmd(sub, a2, hasA2) case "peers": c.peersCmd(sub, hasSub, a2, hasA2) case "monitor": diff --git a/internal/cli/cmds.go b/internal/cli/cmds.go index f99f1c1..68ec950 100644 --- a/internal/cli/cmds.go +++ b/internal/cli/cmds.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "io" + "net" "net/http" "os" "path/filepath" @@ -61,6 +62,10 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) { sec, sok := j.Get2("s").(string) port, pok := isPosInt(j.Get2("p"), 65536) id, iok := isPosInt(j.Get2("n"), 9007199254740992) + // Нода #0 — этот же сервер после переноса панели (`migrate import` выдаёт строку с n = 0). + if n, ok := j.Get2("n").(float64); ok && n == 0 { + id, iok = 0, true + } if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) || !iok || !sok || !reJoinSec.MatchString(sec) { errorf(" Ссылка неверного формата или от другой версии панели") @@ -71,7 +76,8 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) { } hasPanel := os.Getenv("UI_PASS") != "" - keepPanel := !hasFlag && hasPanel + moved := c.panelMoved() + keepPanel := !hasFlag && hasPanel && !moved content := c.readEnvFile() np := "off" if keepPanel { @@ -81,11 +87,18 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) { {"JOIN_SECRET", sec}, {"NODE_PANEL", np}} { content = updateEnvVar(content, kv[0], kv[1]) } + content = removeEnvVar(content, "CORE_ENDPOINT") // адрес прежней панели к новой не относится + relay := "" + if moved { + relay = relayRoutes(h, port, j) + content = updateEnvVar(content, "RELAY", relay) + } c.writeEnvFile(content) // Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше // некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их). - dropPeers := !keepPanel && hasPanel && c.askDropPanelPeers() + // После переноса этими пирами управляет новая панель: снимать нечего. + dropPeers := !keepPanel && hasPanel && !moved && c.askDropPanelPeers() if !keepPanel { c.stop("ui") } @@ -99,9 +112,35 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) { tail = " · своя панель остановлена" } fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail) + if relay != "" { + fmt.Println(" " + dim("Ретранслятор прежних портов на новую панель: "+relay)) + } c.restartFresh() } +// relayRoutes — прежние порты этого сервера → новая панель. Подписка и хаб там на тех же +// портах (так их ставит `migrate import`), порт панели — из поля ui join-строки. +func relayRoutes(host string, hubPort int64, j jsjson.Object) string { + hp := func(p any) string { return net.JoinHostPort(host, fmt.Sprint(p)) } + var routes []string + add := func(local int, target string) { + if local > 0 { + routes = append(routes, fmt.Sprintf("%d=%s", local, target)) + } + } + sub := envPort("SUB_PORT") + if p, ok := isPosInt(j.Get2("sub"), 65536); ok { + add(sub, hp(p)) + } else { + add(sub, hp(sub)) + } + add(envPort("NODE_PORT"), hp(hubPort)) + if p, ok := isPosInt(j.Get2("ui"), 65536); ok { + add(envPort("UI_PORT"), hp(p)) + } + return strings.Join(routes, ",") +} + func (c *CLI) unjoinCore() { if !c.isNode { fmt.Println(" Эта установка не подключена к core") @@ -109,7 +148,7 @@ func (c *CLI) unjoinCore() { } c.stop("agent") content := c.readEnvFile() - for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"} { + for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL", "CORE_ENDPOINT"} { content = removeEnvVar(content, k) } c.writeEnvFile(content) diff --git a/internal/cli/migrate.go b/internal/cli/migrate.go new file mode 100644 index 0000000..b3dd34a --- /dev/null +++ b/internal/cli/migrate.go @@ -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 |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() +} diff --git a/internal/cli/migrate_test.go b/internal/cli/migrate_test.go new file mode 100644 index 0000000..8fde22c --- /dev/null +++ b/internal/cli/migrate_test.go @@ -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) + } +} diff --git a/internal/envfile/envfile.go b/internal/envfile/envfile.go new file mode 100644 index 0000000..1f42e5c --- /dev/null +++ b/internal/envfile/envfile.go @@ -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) +} diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go new file mode 100644 index 0000000..6948819 --- /dev/null +++ b/internal/migrate/migrate.go @@ -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)) +} diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go new file mode 100644 index 0000000..c530242 --- /dev/null +++ b/internal/migrate/migrate_test.go @@ -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