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