Compare commits
2
Commits
v0.4.4
..
45c96aa0b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45c96aa0b6 | ||
|
|
eb19cacf93 |
@@ -151,7 +151,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
||||
|
||||
| Метод | Ответ |
|
||||
|---|---|
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.5" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||
|
||||
@@ -198,8 +198,9 @@ base64url-декодировать, отбросить первые 4 байта
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`; `:id` в путях — id или `uuid` |
|
||||
| `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 '<awgjoin://…>' [--no-panel] / unjoin
|
||||
awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес
|
||||
awg-ctrl migrate export [файл] # перенос панели на отдельный сервер без VPN
|
||||
awg-ctrl migrate import <архив> # (порядок — docs/node-protocol.md, «Перенос панели»)
|
||||
awg-ctrl migrate cancel
|
||||
```
|
||||
|
||||
Через systemd (автозапуск на загрузке):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-agent",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
+63
-3
@@ -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<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "7d": [604800, 1800], "30d": [2592000, 7200] };
|
||||
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
|
||||
@@ -248,11 +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) => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -102,9 +102,16 @@ export const IcoPulse = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcoMove = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="8" height="16" rx="2"/><rect x="14" y="4" width="8" height="16" rx="2"/><path d="M7 12h10"/><path d="m14 9 3 3-3 3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ICONS: Record<string, () => JSX.Element> = {
|
||||
users: IcoUsers,
|
||||
key: IcoKey,
|
||||
shield: IcoShield,
|
||||
pulse: IcoPulse,
|
||||
move: IcoMove,
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { apiFetch, genLabel, type NodeInfo } from '../../lib/shared';
|
||||
|
||||
// «Серверы для всех»: один набор правок на все мастер-ключи (POST /ui/masterkeys/servers).
|
||||
// У каждого сервера — «у всех», «ни у кого» или «как есть» (если он стоит только у части ключей).
|
||||
// Перед применением сервер считает, сколько ключей изменится и какие ключи API потеряют доступ к
|
||||
// части из них (ключ виден API-ключу, только пока все его серверы в области).
|
||||
type Pick = 'all' | 'none' | 'keep';
|
||||
interface Preview { changed: number; total: number; hidden: { id: number; label: string; keys: number }[] }
|
||||
|
||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||
|
||||
export default function BulkServersModal({ token, nodes, keys, defaults, onClose, onDone }: {
|
||||
token: string;
|
||||
nodes: NodeInfo[];
|
||||
keys: { servers: number[] }[]; // живые мастер-ключи
|
||||
defaults: number[] | null;
|
||||
onClose: () => void;
|
||||
onDone: (changed: number) => void;
|
||||
}) {
|
||||
const total = keys.length;
|
||||
const count = (id: number) => keys.filter(k => k.servers.includes(id)).length;
|
||||
const initial = (id: number): Pick => { const n = count(id); return n === total && total > 0 ? 'all' : n === 0 ? 'none' : 'keep'; };
|
||||
const [picks, setPicks] = useState<Record<number, Pick>>(() => Object.fromEntries(nodes.map(n => [n.id, initial(n.id)])));
|
||||
const [forNew, setForNew] = useState(true);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { add, remove } = useMemo(() => ({
|
||||
add: nodes.filter(n => picks[n.id] === 'all' && count(n.id) < total).map(n => n.id),
|
||||
remove: nodes.filter(n => picks[n.id] === 'none' && count(n.id) > 0).map(n => n.id),
|
||||
}), [picks, nodes, keys]);
|
||||
const all = nodes.filter(n => picks[n.id] === 'all').map(n => n.id);
|
||||
const sameDefaults = JSON.stringify([...all].sort()) === JSON.stringify([...(defaults ?? [])].sort());
|
||||
const body = { add, remove, ...(forNew && !sameDefaults ? { defaults: all.length ? all : null } : {}) };
|
||||
const nothing = !add.length && !remove.length && !('defaults' in body);
|
||||
|
||||
useEffect(() => {
|
||||
if (!add.length && !remove.length) { setPreview(null); setError(''); return; }
|
||||
let live = true;
|
||||
apiFetch('POST', '/ui/masterkeys/servers', token, { add, remove, dry_run: true })
|
||||
.then(p => { if (live) { setPreview(p); setError(''); } })
|
||||
.catch(e => { if (live) { setPreview(null); setError(errText(e)); } });
|
||||
return () => { live = false; };
|
||||
}, [token, add, remove]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const apply = async () => {
|
||||
setBusy(true);
|
||||
try { const r = await apiFetch('POST', '/ui/masterkeys/servers', token, body); onDone(r.changed ?? 0); }
|
||||
catch (e) { setError(errText(e)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const option = (id: number, p: Pick, label: string) => (
|
||||
<button type="button" className={`bs-opt${picks[id] === p ? ' bs-opt--on' : ''}`} aria-pressed={picks[id] === p}
|
||||
onClick={() => setPicks(prev => ({ ...prev, [id]: p }))}>{label}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="node-scrim" onClick={onClose}>
|
||||
<div className="node-modal bs-modal" role="dialog" aria-modal="true" aria-label="Серверы для всех ключей" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="node-modal-title">Серверы для всех ключей</h3>
|
||||
<p className="node-modal-hint">
|
||||
Применится ко всем мастер-ключам сразу ({total}). Пользователям ничего делать не нужно: приложение получит
|
||||
новый набор серверов при следующем обновлении подписки, ссылки и ключи остаются прежними.
|
||||
</p>
|
||||
<div className="bs-list">
|
||||
{nodes.map(n => {
|
||||
const c = count(n.id), partial = c > 0 && c < total;
|
||||
return (
|
||||
<div className="bs-row" key={n.id}>
|
||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<span className="bs-name">
|
||||
<span className="server-pick-name">{n.health?.server || n.name}</span>
|
||||
<span className="bs-sub">
|
||||
{n.health?.ip && <code className="server-pick-ip">{n.health.ip}</code>}
|
||||
{n.health?.gen && <span>{genLabel(n.health.gen)}</span>}
|
||||
<span>сейчас у {c} из {total}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="bs-opts" role="group" aria-label={`Сервер ${n.name}`}>
|
||||
{option(n.id, 'all', 'У всех')}
|
||||
{partial && option(n.id, 'keep', 'Как есть')}
|
||||
{option(n.id, 'none', 'Ни у кого')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="bs-check">
|
||||
<input type="checkbox" checked={forNew} onChange={e => setForNew(e.target.checked)} />
|
||||
<span>Новым ключам — серверы «У всех»{all.length ? '' : ' (не выбрано ни одного — как раньше: все подключённые)'}</span>
|
||||
</label>
|
||||
{preview && (
|
||||
<p className="node-modal-hint">Изменится ключей: {preview.changed} из {preview.total}.</p>
|
||||
)}
|
||||
{preview?.hidden.map(h => (
|
||||
<p className="mk-notice" key={h.id}>
|
||||
Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области.
|
||||
Расширьте его область на вкладке «API-ключи», если он ими управляет.
|
||||
</p>
|
||||
))}
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<div className="node-modal-actions">
|
||||
<button className="btn btn--outline" onClick={onClose}>Отмена</button>
|
||||
<button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}>
|
||||
{busy ? 'Применяем…' : 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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<number[] | null>(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)} />
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
|
||||
<button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length}
|
||||
title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -518,6 +525,11 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{bulk && (
|
||||
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
|
||||
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<div className="qr-backdrop" onClick={() => setModal(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch, copyText, type PageProps } from '../../lib/shared';
|
||||
import { IcoCopy } from '../../components/icons';
|
||||
import './migrate.css';
|
||||
|
||||
// Вкладка «Перенос»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
|
||||
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
|
||||
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
|
||||
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
|
||||
type MoveState = '' | 'sending' | 'moved' | 'uncertain' | 'imported';
|
||||
// Новая панель после приёма: кто уже знает её адрес — тогда старый сервер можно выключать.
|
||||
interface Retire {
|
||||
since: number;
|
||||
home: string | null; // адрес хаба для нод; null — не задан SUB_HOST
|
||||
old: { exists: boolean; online: boolean };
|
||||
devices: { total: number; fresh: number }; // fresh — получили конфиг после переноса
|
||||
unused_links: number; // мастер-ключи без устройств: в ссылке старый адрес
|
||||
nodes: { id: number; name: string; online: boolean; ok: boolean }[];
|
||||
}
|
||||
interface MigrateInfo {
|
||||
state: MoveState;
|
||||
error: string | null;
|
||||
target: string | null; // старая: адрес новой панели
|
||||
code: { code: string; expires_in: number } | null;
|
||||
export?: string | null; // null — можно, строка — почему нельзя
|
||||
accept?: string | null;
|
||||
retire?: Retire | null;
|
||||
}
|
||||
|
||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||
|
||||
export default function MigratePage({ token, showMsg }: PageProps) {
|
||||
const [info, setInfo] = useState<MigrateInfo | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setInfo(await apiFetch('GET', '/ui/migrate', token)); }
|
||||
catch { /* панель перезапускается — следующий опрос покажет */ }
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
// Пока висит код — ждём, когда старая панель пришлёт архив; после приёма панель
|
||||
// перезапускается, и страница перечитывается уже с перенесённой базой.
|
||||
const waiting = !!info?.code || info?.state === 'imported' || !!info?.retire;
|
||||
useEffect(() => {
|
||||
if (!waiting) return;
|
||||
const t = setInterval(load, 3000);
|
||||
return () => clearInterval(t);
|
||||
}, [waiting, load]);
|
||||
useEffect(() => {
|
||||
if (info?.state !== 'imported' || info.error) return;
|
||||
const t = setTimeout(() => window.location.reload(), 8000);
|
||||
return () => clearTimeout(t);
|
||||
}, [info?.state, info?.error]);
|
||||
|
||||
const call = async (method: string, path: string, body?: object) => {
|
||||
setBusy(true); setError('');
|
||||
try { setInfo(await apiFetch(method, `/ui/migrate${path}`, token, body)); }
|
||||
catch (e) { setError(errText(e)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const send = async () => {
|
||||
if (!window.confirm('Перенести панель? Здесь она остановится, а этот сервер станет нодой новой панели.')) return;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await apiFetch('POST', '/ui/migrate/export', token, { code: code.trim() });
|
||||
await load();
|
||||
} catch (e) { setError(errText(e)); await load(); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
if (info.state === 'moved') return (
|
||||
<section className="mv-box">
|
||||
<span className="chip chip--online mv-self"><span className="chip-dot" />Панель перенесена</span>
|
||||
<p className="mv-text">
|
||||
Новая панель: <a href={info.target ?? '#'}>{info.target}</a>. Через несколько секунд этот сервер станет её
|
||||
нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать,
|
||||
а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.
|
||||
</p>
|
||||
{info.target && <a className="btn btn--primary mv-self" href={info.target}>Открыть новую панель</a>}
|
||||
</section>
|
||||
);
|
||||
|
||||
if (info.state === 'imported') return (
|
||||
<section className="mv-box">
|
||||
<span className={`chip chip--${info.error ? 'error' : 'online'} mv-self`}><span className="chip-dot" />
|
||||
{info.error ? 'Приём прерван' : 'Панель принята'}</span>
|
||||
<p className="mv-text">
|
||||
{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
const r = info.retire;
|
||||
const retireBox = r && (() => {
|
||||
const devicesOk = r.devices.fresh >= r.devices.total;
|
||||
const nodesOk = r.nodes.every(n => n.ok);
|
||||
const ready = devicesOk && nodesOk && !!r.home;
|
||||
const chip = (ok: boolean, text: string, offline = false) => (
|
||||
<span className={`chip chip--${ok ? 'online' : offline ? 'offline' : 'warm'}`}><span className="chip-dot" />{text}</span>
|
||||
);
|
||||
return (
|
||||
<section className="mv-box" key="retire">
|
||||
<h4 className="mv-title">Отключение старого сервера</h4>
|
||||
<p className="mv-text">
|
||||
{r.old.exists
|
||||
? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его
|
||||
можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними.</>
|
||||
: <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.</>}
|
||||
</p>
|
||||
<div className="mv-checks">
|
||||
<div className="mv-check">
|
||||
{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)}
|
||||
<span>устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}</span>
|
||||
</div>
|
||||
{r.nodes.map(n => (
|
||||
<div className="mv-check" key={n.id}>
|
||||
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)}
|
||||
<span>нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}</span>
|
||||
</div>
|
||||
))}
|
||||
{r.unused_links > 0 && (
|
||||
<div className="mv-check">
|
||||
{chip(false, String(r.unused_links), true)}
|
||||
<span>мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на
|
||||
вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!r.home && <p className="mv-notice">Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.</p>}
|
||||
<ol className="mv-steps">
|
||||
<li>Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.</li>
|
||||
<li>Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.</li>
|
||||
<li>Удалите сервер «#0» и выключите старую машину.</li>
|
||||
</ol>
|
||||
{!r.old.exists && (
|
||||
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('DELETE', '/retire')}>Готово, скрыть</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})();
|
||||
|
||||
const canExport = info.export === null, canAccept = info.accept === null;
|
||||
const exportBox = (
|
||||
<section className="mv-box" key="export">
|
||||
<h4 className="mv-title">Перенести эту панель на другой сервер</h4>
|
||||
{canExport ? (
|
||||
<>
|
||||
<p className="mv-text">
|
||||
Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели.
|
||||
Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.
|
||||
</p>
|
||||
<ol className="mv-steps">
|
||||
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
|
||||
<li>Там на вкладке «Перенос» нажмите «Получить код».</li>
|
||||
<li>Вставьте код сюда и нажмите «Перенести».</li>
|
||||
</ol>
|
||||
{info.state === 'uncertain' ? (
|
||||
<>
|
||||
<p className="mv-notice">{info.error}</p>
|
||||
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('POST', '/cancel')}>
|
||||
Отменить перенос
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="mv-row">
|
||||
<input className="field" placeholder="awgmove://…" value={code} disabled={busy}
|
||||
onChange={e => setCode(e.target.value)} />
|
||||
<button className="btn btn--primary" disabled={busy || !code.trim().startsWith('awgmove://')} onClick={send}>
|
||||
{busy ? 'Переносим…' : 'Перенести'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : <p className="mv-text mv-muted">{info.export}</p>}
|
||||
</section>
|
||||
);
|
||||
const acceptBox = (
|
||||
<section className="mv-box" key="accept">
|
||||
<h4 className="mv-title">Принять панель с другого сервера</h4>
|
||||
{canAccept ? (
|
||||
info.code ? (
|
||||
<>
|
||||
<p className="mv-text">
|
||||
Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует
|
||||
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
|
||||
</p>
|
||||
<code className="node-modal-cmd">{info.code.code}</code>
|
||||
<div className="mv-row mv-row--end">
|
||||
<button className="btn btn--tonal" onClick={() => copyText(info.code!.code).then(() => showMsg('Код скопирован'))}>
|
||||
<IcoCopy /> Скопировать
|
||||
</button>
|
||||
<button className="btn btn--outline" disabled={busy} onClick={() => call('DELETE', '/code')}>Отозвать</button>
|
||||
</div>
|
||||
<p className="mv-text mv-muted">Ждём архив от старой панели…</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mv-text">
|
||||
Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней
|
||||
нодой #0. Логин и пароль останутся этой панели.
|
||||
</p>
|
||||
<button className="btn btn--primary mv-self" disabled={busy}
|
||||
onClick={() => call('POST', '/code', { host: window.location.hostname })}>
|
||||
Получить код
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : <p className="mv-text mv-muted">{info.accept}</p>}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Доступное действие — первым. */}
|
||||
{retireBox}
|
||||
{canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]}
|
||||
{error && <p className="mv-notice">{error}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "migrate",
|
||||
"label": "Перенос",
|
||||
"icon": "move",
|
||||
"group": "server",
|
||||
"order": 1.9,
|
||||
"chrome": {
|
||||
"serverBar": false,
|
||||
"restart": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Copyright (c) 2026 Ivan Vasilev
|
||||
This source code is licensed under the MIT license found in the
|
||||
LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
|
||||
|
||||
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
|
||||
.mv-title { margin: 0; font-size: 14px; font-weight: 500; }
|
||||
.mv-text { margin: 0; font-size: 14px; line-height: 1.5; }
|
||||
.mv-muted { font-size: 13px; color: var(--neutral-text); }
|
||||
.mv-steps { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6; }
|
||||
.mv-row { display: flex; gap: 8px; }
|
||||
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; }
|
||||
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
|
||||
.mv-self { align-self: flex-start; }
|
||||
.mv-notice {
|
||||
font-size: 13px; color: var(--error-text); background: var(--error-bg);
|
||||
border-radius: 16px; padding: 10px 16px; margin: 0;
|
||||
}
|
||||
.mv-box .node-modal-cmd { background: var(--surface-bright); }
|
||||
@media (max-width: 640px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
|
||||
.mv-box .btn:disabled { opacity: .5; cursor: default; }
|
||||
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: 14px; line-height: 1.5; }
|
||||
.mv-check .chip { flex-shrink: 0; }
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent и команды оператора.
|
||||
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent / relay и команды оператора.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/maeneko/forgetting/internal/agent"
|
||||
"github.com/maeneko/forgetting/internal/cli"
|
||||
"github.com/maeneko/forgetting/internal/ctrl"
|
||||
"github.com/maeneko/forgetting/internal/relay"
|
||||
"github.com/maeneko/forgetting/internal/ui"
|
||||
)
|
||||
|
||||
@@ -60,6 +61,13 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
a.Run(ctx)
|
||||
case "relay":
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := relay.Main(ctx); err != nil && ctx.Err() == nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
cli.Main(os.Args[1:])
|
||||
}
|
||||
|
||||
+4
-3
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
|
||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||
# При бампе версии правь оба места.
|
||||
VERSION="0.4.4"
|
||||
VERSION="0.4.5"
|
||||
CHANNEL="${CHANNEL:-Beta}"
|
||||
|
||||
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
|
||||
@@ -702,8 +702,9 @@ parse_join() {
|
||||
CORE_PIN=$(sed -nE 's/.*"pin":"([A-Za-z0-9_-]{43})".*/\1/p' <<< "$json")
|
||||
NODE_ID=$(sed -nE 's/.*"n":([0-9]+)[,}].*/\1/p' <<< "$json")
|
||||
JOIN_SECRET=$(sed -nE 's/.*"s":"([A-Za-z0-9_-]{22})".*/\1/p' <<< "$json")
|
||||
[[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" ]] \
|
||||
&& (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID > 0 ))
|
||||
# NODE_ID 0 — сервер, с которого переехала панель (awg-ctrl migrate import).
|
||||
[[ -n "$CORE_HOST" && -n "$CORE_PIN" && -n "$JOIN_SECRET" && -n "$NODE_ID" ]] \
|
||||
&& (( CORE_PORT > 0 && CORE_PORT < 65536 && NODE_ID >= 0 ))
|
||||
}
|
||||
|
||||
# SHA-256 от SPKI сертификата панели, base64url без «=» — в том же виде, что pin в строке.
|
||||
|
||||
+98
-15
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+50
-19
@@ -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 <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>"
|
||||
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>>"
|
||||
|
||||
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
|
||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "peers", "monitor"}
|
||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"}
|
||||
|
||||
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
|
||||
func Main(args []string) {
|
||||
@@ -608,6 +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":
|
||||
|
||||
+42
-3
@@ -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)
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package cli
|
||||
|
||||
// `migrate export|import|cancel` — перенос панели с сервера «панель + VPN» на отдельную панель
|
||||
// без VPN; архив и раскладка — internal/migrate (их же зовёт вкладка «Перенос»):
|
||||
//
|
||||
// старый: migrate export → архив: ui.db, ключи подписки и хаба, порты
|
||||
// новый: migrate import → раскладывает архив, заводит ноду #0 и печатает join-строку
|
||||
// старый: join '<строка>' → становится нодой #0 и поднимает ретранслятор прежних портов
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
"github.com/maeneko/forgetting/internal/migrate"
|
||||
)
|
||||
|
||||
func envPort(k string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (c *CLI) paths() migrate.Paths {
|
||||
return migrate.PathsFrom(func(env string) string { return c.svcEnv("ui", env) })
|
||||
}
|
||||
|
||||
func (c *CLI) migrateCmd(sub, arg string, hasArg bool) {
|
||||
switch sub {
|
||||
case "export":
|
||||
c.migrateExport(arg, hasArg)
|
||||
case "import":
|
||||
if !hasArg {
|
||||
errorf(" Usage: awg-ctrl migrate import <архив>")
|
||||
os.Exit(1)
|
||||
}
|
||||
c.migrateImport(arg)
|
||||
case "cancel":
|
||||
c.migrateCancel()
|
||||
default:
|
||||
errorf(" Usage: awg-ctrl migrate <export [файл]|import <архив>|cancel>")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── export (старый сервер) ─────────────────────────────────────────────────
|
||||
|
||||
func (c *CLI) migrateExport(out string, hasOut bool) {
|
||||
if c.isNode {
|
||||
errorf(" Это нода: её панель — на core, переносить отсюда нечего")
|
||||
os.Exit(1)
|
||||
}
|
||||
if !contains(c.svcList(), "awgctrl") {
|
||||
errorf(" У этой панели нет своего VPN (LOCAL_NODE=off) — перенос рассчитан на сервер «панель + VPN»")
|
||||
os.Exit(1)
|
||||
}
|
||||
dbFile := c.paths()["ui.db"]
|
||||
if !exists(dbFile) {
|
||||
errorf(" Нет базы панели: %s", dbFile)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !hasOut {
|
||||
out = fmt.Sprintf("forgetting-migrate-%s.tar.gz", time.Now().Format("20060102-150405"))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Перенос панели на отдельный сервер. Сейчас:")
|
||||
fmt.Println(" · панель и подписка sen:// здесь остановятся и больше не запустятся сами;")
|
||||
fmt.Println(" · VPN продолжит работать: пиры и awg-ctrl не трогаются;")
|
||||
fmt.Println(" · архив с базой панели и ключами подписки — " + bold(out))
|
||||
if !c.confirmYes(" Продолжить?") {
|
||||
return
|
||||
}
|
||||
|
||||
c.stop("ui")
|
||||
// Флаг — до снимка: перезапуск между ними поднял бы панель с базой, которая уже уехала.
|
||||
content := updateEnvVar(c.readEnvFile(), "PANEL_MOVED", "on")
|
||||
if err := c.writeEnvFile(content); err != nil {
|
||||
errorf(" Не удалось записать %s: %v", c.envFile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := c.writeExport(out, dbFile); err != nil {
|
||||
errorf(" Архив не создан: %v", err)
|
||||
c.writeEnvFile(removeEnvVar(c.readEnvFile(), "PANEL_MOVED"))
|
||||
os.Remove(out)
|
||||
c.start("ui")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("\n %s Архив: %s %s\n", green("✓"), out, dim("(в нём ключ подписи — храните как пароль, после переноса удалите)"))
|
||||
fmt.Println("\n Дальше:")
|
||||
fmt.Println(" 1. Новая панель: install.sh, роль «только панель»")
|
||||
fmt.Println(" 2. Скопируйте архив туда: " + dim("scp "+out+" root@<новая-панель>:"))
|
||||
fmt.Println(" 3. На новой панели: " + bold("awg-ctrl migrate import "+filepath.Base(out)))
|
||||
fmt.Println(" 4. Здесь — строку, которую она напечатает: " + bold("awg-ctrl join '<строка>'"))
|
||||
fmt.Println(" " + dim("Передумали до шага 4: awg-ctrl migrate cancel"))
|
||||
}
|
||||
|
||||
// svcList — сервисы роли без поправки на PANEL_MOVED и ретранслятор.
|
||||
func (c *CLI) svcList() []string {
|
||||
return without(without(c.all, "relay"), "ui")
|
||||
}
|
||||
|
||||
func (c *CLI) writeExport(out, dbFile string) error {
|
||||
db, err := migrate.OpenDB(dbFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
name, ip := "VPN", os.Getenv("SERVER_IP")
|
||||
if h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health"); h != nil {
|
||||
name = strOr(get(h, "server"), name)
|
||||
if ip == "" {
|
||||
ip = strOr(get(h, "ip"), "")
|
||||
}
|
||||
}
|
||||
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = migrate.Write(f, db, c.paths(), exportMeta(c.version, name, ip, envPort("UI_PORT"), envPort("SUB_PORT"),
|
||||
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT")))
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
|
||||
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int) jsjson.Object {
|
||||
return jsjson.Object{
|
||||
{Key: "version", Val: version}, {Key: "created_at", Val: time.Now().Unix()},
|
||||
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
|
||||
{Key: "ui_port", Val: uiPort}, {Key: "sub_port", Val: subPort}, {Key: "sub_tls", Val: subTLS}, {Key: "node_port", Val: nodePort},
|
||||
}
|
||||
}
|
||||
|
||||
// ── import (новая панель) ──────────────────────────────────────────────────
|
||||
|
||||
func (c *CLI) migrateImport(file string) {
|
||||
if c.isNode {
|
||||
errorf(" Это нода — импортировать панель нужно на core")
|
||||
os.Exit(1)
|
||||
}
|
||||
if contains(c.svcList(), "awgctrl") {
|
||||
errorf(" У этой панели свой VPN (LOCAL_NODE=on). Импорт — на панель без VPN: install.sh, роль «только панель»")
|
||||
os.Exit(1)
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
errorf(" %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
b, err := migrate.Read(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
errorf(" %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
paths := c.paths()
|
||||
if n, err := migrate.PanelData(paths["ui.db"]); err != nil || n > 0 {
|
||||
errorf(" На этой панели уже есть данные (%s) — импорт их затёр бы. Нужна чистая панель.", paths["ui.db"])
|
||||
os.Exit(1)
|
||||
}
|
||||
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
|
||||
for host == "" || !reJoinHost.MatchString(host) {
|
||||
host = strings.TrimSpace(c.ask(" Публичный адрес этой панели (IP или домен): "))
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
plan, err := migrate.NewPlan(b, envPort, envPort("UI_PORT"), host)
|
||||
if err != nil {
|
||||
errorf(" %v (%s)", err, c.envFile)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
from := b.Str("server_ip")
|
||||
if v := b.Str("version"); v != "" {
|
||||
from += ", версия " + v
|
||||
}
|
||||
fmt.Printf(" Импорт панели «%s» (%s)\n", b.Str("server_name"), from)
|
||||
fmt.Printf(" · подписка :%d, хаб нод :%d, адрес панели %s\n", plan.SubPort, plan.NodePort, host)
|
||||
fmt.Println(" · прежние файлы панели уйдут в *.bak-<время>")
|
||||
if !b.Has["sub_sign.key"] {
|
||||
fmt.Println(" · " + dim("ключа подписи в архиве нет — на старом сервере подписка не была настроена"))
|
||||
}
|
||||
if !c.confirmYes(" Продолжить?") {
|
||||
return
|
||||
}
|
||||
|
||||
c.stop("ui")
|
||||
secret := make([]byte, 16)
|
||||
rand.Read(secret)
|
||||
if err := migrate.Install(b, paths); err != nil {
|
||||
errorf(" %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
|
||||
errorf(" ui.db: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
content := c.readEnvFile()
|
||||
for _, kv := range plan.Env {
|
||||
content = updateEnvVar(content, kv[0], kv[1])
|
||||
}
|
||||
if err := c.writeEnvFile(content); err != nil {
|
||||
errorf(" Не удалось записать %s: %v", c.envFile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
c.restartFresh()
|
||||
|
||||
// Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте.
|
||||
var pin string
|
||||
for range 50 {
|
||||
if pin, err = migrate.CertPin(paths["node_tls.crt"]); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
errorf(" Нет сертификата хаба %s — проверьте лог awg-ui и выдайте строку в панели (сервер #0)", paths["node_tls.crt"])
|
||||
os.Exit(1)
|
||||
}
|
||||
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
|
||||
fmt.Printf("\n %s Панель перенесена. На старом сервере выполните (строка действует час):\n\n", green("✓"))
|
||||
fmt.Println(" awg-ctrl join '" + join + "'")
|
||||
fmt.Println()
|
||||
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
|
||||
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
|
||||
}
|
||||
|
||||
// ── cancel (старый сервер) ─────────────────────────────────────────────────
|
||||
|
||||
func (c *CLI) migrateCancel() {
|
||||
if !c.panelMoved() {
|
||||
fmt.Println(" Переноса не было: панель на месте")
|
||||
return
|
||||
}
|
||||
if c.isNode {
|
||||
errorf(" Сервер уже нода новой панели — база там, и вернуть её сюда так нельзя.")
|
||||
errorf(" Если всё же нужно: awg-ctrl unjoin, затем верните ui.db с новой панели вручную.")
|
||||
os.Exit(1)
|
||||
}
|
||||
content := c.readEnvFile()
|
||||
for _, k := range []string{"PANEL_MOVED", "RELAY"} {
|
||||
content = removeEnvVar(content, k)
|
||||
}
|
||||
c.writeEnvFile(content)
|
||||
fmt.Printf(" %s Перенос отменён, панель запускается здесь снова\n", green("✓"))
|
||||
fmt.Println(" " + dim("Если архив уже импортирован на новую панель — не запускайте там ui, пока она не очищена"))
|
||||
c.restartFresh()
|
||||
}
|
||||
|
||||
// ── rehome (нода) ──────────────────────────────────────────────────────────
|
||||
|
||||
// rehome — панель переехала на новый адрес: тот же pin и id, другой CORE_HOST[:CORE_PORT].
|
||||
func (c *CLI) rehome(addr string, hasAddr bool) {
|
||||
if !c.isNode {
|
||||
errorf(" Это не нода — менять адрес core нечему")
|
||||
os.Exit(1)
|
||||
}
|
||||
host, port := addr, ""
|
||||
if h, p, err := net.SplitHostPort(addr); err == nil {
|
||||
host, port = h, p
|
||||
}
|
||||
if p, err := strconv.Atoi(port); !hasAddr || !reJoinHost.MatchString(host) || port != "" && (err != nil || p <= 0 || p > 65535) {
|
||||
errorf(" Usage: awg-ctrl rehome <хост[:порт]> (адрес новой панели; pin и id ноды остаются)")
|
||||
os.Exit(1)
|
||||
}
|
||||
content := updateEnvVar(c.readEnvFile(), "CORE_HOST", host)
|
||||
if port != "" {
|
||||
content = updateEnvVar(content, "CORE_PORT", port)
|
||||
} else {
|
||||
port = os.Getenv("CORE_PORT")
|
||||
}
|
||||
// Адрес, указанный руками, главнее того, что сообщила панель.
|
||||
c.writeEnvFile(removeEnvVar(content, "CORE_ENDPOINT"))
|
||||
fmt.Printf(" %s Core: %s:%s\n", green("✓"), host, port)
|
||||
c.restartFresh()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
"github.com/maeneko/forgetting/internal/migrate"
|
||||
)
|
||||
|
||||
func decodeJoin(t *testing.T, s string) jsjson.Object {
|
||||
t.Helper()
|
||||
v, err := jsjson.Decode(fromB64url(strings.TrimPrefix(s, "awgjoin://")))
|
||||
o, ok := v.(jsjson.Object)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("join-строка не разбирается: %v", err)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// export берёт пути и порты из окружения сервисов, как их видит awg-ui.
|
||||
func TestWriteExportFromEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sign := filepath.Join(dir, "sign")
|
||||
os.WriteFile(sign, []byte("sign"), 0o600)
|
||||
dbFile := filepath.Join(dir, "ui.db")
|
||||
db, err := migrate.OpenDB(dbFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Exec("CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||
db.Exec("INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')")
|
||||
db.Close()
|
||||
for k, v := range map[string]string{
|
||||
"AWG_ROOT": dir, "UI_DB_FILE": dbFile, "SUB_SIGN_KEY_FILE": sign, "AWGCTRL_PORT": "1", "SERVER_IP": "198.51.100.1",
|
||||
"SUB_PORT": "40123", "SUB_TLS": "on", "NODE_PORT": "40500", "UI_PORT": "33000",
|
||||
"SUB_TLS_KEY_FILE": filepath.Join(dir, "нет"), "SUB_TLS_CERT_FILE": filepath.Join(dir, "нет"),
|
||||
"NODE_TLS_KEY_FILE": filepath.Join(dir, "нет"), "NODE_TLS_CERT_FILE": filepath.Join(dir, "нет"),
|
||||
} {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
out := filepath.Join(dir, "bundle.tar.gz")
|
||||
if err := New().writeExport(out, dbFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st, _ := os.Stat(out); st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("права архива %v — в нём ключ подписи", st.Mode().Perm())
|
||||
}
|
||||
f, _ := os.Open(out)
|
||||
b, err := migrate.Read(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
if b.Str("panel_id") != "abcdef123456" || b.Str("server_ip") != "198.51.100.1" || b.Str("server_name") != "VPN" ||
|
||||
b.Num("sub_port") != 40123 || b.Num("node_port") != 40500 || b.Num("ui_port") != 33000 || b.Meta.Get2("sub_tls") != true ||
|
||||
!b.Has["sub_sign.key"] || b.Has["sub_tls.key"] {
|
||||
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayRoutes(t *testing.T) {
|
||||
j := decodeJoin(t, migrate.JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123))
|
||||
t.Setenv("SUB_PORT", "40123")
|
||||
t.Setenv("NODE_PORT", "40400")
|
||||
t.Setenv("UI_PORT", "32000")
|
||||
if got, want := relayRoutes("203.0.113.7", 40500, j), "40123=203.0.113.7:40123,40400=203.0.113.7:40500,32000=203.0.113.7:33100"; got != want {
|
||||
t.Errorf("relay = %s, ожидали %s", got, want)
|
||||
}
|
||||
// Строка, выданная панелью заново (без ui/sub): подписка — на тот же порт, панель не пересылаем.
|
||||
t.Setenv("NODE_PORT", "")
|
||||
plain := decodeJoin(t, migrate.JoinString("2001:db8::7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 0, 0))
|
||||
if got, want := relayRoutes("2001:db8::7", 40500, plain), "40123=[2001:db8::7]:40123"; got != want {
|
||||
t.Errorf("relay = %s, ожидали %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package envfile — правка cli.env (KEY=value построчно): CLI и панель пишут в него одинаково.
|
||||
package envfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Update — как String.replace без g: первое KEY=…, а нет его — строка в конец.
|
||||
func Update(content, key, value string) string {
|
||||
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
|
||||
if loc := re.FindStringIndex(content); loc != nil {
|
||||
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
|
||||
}
|
||||
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
|
||||
}
|
||||
|
||||
func Remove(content, key string) string {
|
||||
var kept []string
|
||||
for _, l := range strings.Split(content, "\n") {
|
||||
if !strings.HasPrefix(l, key+"=") {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// Set — задать set и убрать remove в файле. Новый файл — 0600 (в нём пароль панели).
|
||||
func Set(file string, set [][2]string, remove ...string) error {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
content := string(raw)
|
||||
for _, kv := range set {
|
||||
content = Update(content, kv[0], kv[1])
|
||||
}
|
||||
for _, k := range remove {
|
||||
content = Remove(content, k)
|
||||
}
|
||||
return os.WriteFile(file, []byte(content), 0o600)
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Package migrate — перенос панели с сервера «панель + VPN» на отдельную панель без VPN: архив
|
||||
// (ui.db, ключи подписки и хаба, порты), его раскладка на новой панели и нода #0. Общий для
|
||||
// CLI (`awg-control migrate`) и вкладки «Перенос» в панели. Порядок и смысл —
|
||||
// docs/node-protocol.md, «Перенос панели».
|
||||
//
|
||||
// Старый сервер остаётся нодой #0: id сервера в ui.db не меняется, поэтому конфиг клиента
|
||||
// (servers[] и ключи) тот же байт в байт, а пиры на его awg-ctrl уже принадлежат области
|
||||
// перенесённой панели (sub_meta.panel_id едет вместе с ui.db).
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
var b64url = base64.RawURLEncoding
|
||||
|
||||
const (
|
||||
Version = 1
|
||||
maxFile = 1 << 30 // ui.db крупнее гигабайта — явно не то
|
||||
JoinTTL = 3600 // как у панели: одноразовый секрет живёт час
|
||||
)
|
||||
|
||||
// File — что едет в архиве: имя внутри архива и переменная пути в окружении awg-ui.
|
||||
type File struct {
|
||||
Name, Env string
|
||||
Required bool
|
||||
}
|
||||
|
||||
var Files = []File{
|
||||
{"ui.db", "UI_DB_FILE", true},
|
||||
// 🛑 Ключ подписи и TLS подписки зашиты во все выданные sen://, сертификат хаба — во все
|
||||
// join-строки и подключённые ноды: без них перенос ломает и ссылки, и ноды.
|
||||
{"sub_sign.key", "SUB_SIGN_KEY_FILE", false},
|
||||
{"sub_tls.key", "SUB_TLS_KEY_FILE", false},
|
||||
{"sub_tls.crt", "SUB_TLS_CERT_FILE", false},
|
||||
{"node_tls.key", "NODE_TLS_KEY_FILE", false},
|
||||
{"node_tls.crt", "NODE_TLS_CERT_FILE", false},
|
||||
}
|
||||
|
||||
// Paths — имя файла в архиве → путь на этой машине.
|
||||
type Paths map[string]string
|
||||
|
||||
// PathsFrom — пути из переменных окружения awg-ui (get(env) → путь).
|
||||
func PathsFrom(get func(env string) string) Paths {
|
||||
p := Paths{}
|
||||
for _, f := range Files {
|
||||
p[f.Name] = get(f.Env)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// OpenDB — ui.db тем же драйвером, что у awg-ui.
|
||||
func OpenDB(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", "file:"+(&url.URL{Path: path}).EscapedPath()+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ── Архив ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Write — архив в w: снимок db (VACUUM INTO — целостный, вместе с WAL), файлы из p и meta
|
||||
// (+ panel_id из базы). Отсутствующие необязательные файлы пропускаются.
|
||||
func Write(w io.Writer, db *sql.DB, p Paths, meta jsjson.Object) error {
|
||||
tmp, err := os.MkdirTemp("", "forgetting-migrate-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
snap := filepath.Join(tmp, "ui.db")
|
||||
if _, err := db.Exec("VACUUM INTO ?", snap); err != nil {
|
||||
return fmt.Errorf("снимок ui.db: %w", err)
|
||||
}
|
||||
var panelID string
|
||||
db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&panelID)
|
||||
meta = append(jsjson.Object{{Key: "v", Val: Version}, {Key: "panel_id", Val: panelID}}, meta...)
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
tw := tar.NewWriter(gz)
|
||||
add := func(name string, data []byte) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tw.Write(data)
|
||||
return err
|
||||
}
|
||||
err = add("meta.json", jsjson.MustMarshal(meta))
|
||||
for _, f := range Files {
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
src := p[f.Name]
|
||||
if f.Name == "ui.db" {
|
||||
src = snap
|
||||
}
|
||||
raw, rerr := os.ReadFile(src)
|
||||
if errors.Is(rerr, os.ErrNotExist) && !f.Required {
|
||||
continue
|
||||
}
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
break
|
||||
}
|
||||
err = add(f.Name, raw)
|
||||
}
|
||||
if cerr := tw.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if cerr := gz.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
Dir string
|
||||
Meta jsjson.Object
|
||||
Has map[string]bool
|
||||
}
|
||||
|
||||
func (b *Bundle) Close() { os.RemoveAll(b.Dir) }
|
||||
|
||||
func (b *Bundle) Num(k string) int {
|
||||
f, _ := b.Meta.Get2(k).(float64)
|
||||
return int(f)
|
||||
}
|
||||
|
||||
func (b *Bundle) Str(k string) string {
|
||||
s, _ := b.Meta.Get2(k).(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Read — распаковка во временный каталог; в архиве допустимы только известные имена.
|
||||
func Read(r io.Reader) (*Bundle, error) {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не архив переноса: %w", err)
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "forgetting-import-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := &Bundle{Dir: dir, Has: map[string]bool{}}
|
||||
fail := func(err error) (*Bundle, error) { b.Close(); return nil, err }
|
||||
allowed := map[string]bool{"meta.json": true}
|
||||
for _, f := range Files {
|
||||
allowed[f.Name] = true
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("архив повреждён: %w", err))
|
||||
}
|
||||
if h.Typeflag != tar.TypeReg || !allowed[h.Name] || b.Has[h.Name] || h.Size > maxFile {
|
||||
return fail(fmt.Errorf("в архиве лишнее: %q", h.Name))
|
||||
}
|
||||
dst, err := os.OpenFile(filepath.Join(dir, h.Name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err == nil {
|
||||
_, err = io.Copy(dst, io.LimitReader(tr, maxFile))
|
||||
if cerr := dst.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
b.Has[h.Name] = true
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||
var v any
|
||||
if err == nil {
|
||||
v, err = jsjson.Decode(raw)
|
||||
}
|
||||
meta, ok := v.(jsjson.Object)
|
||||
if err != nil || !ok || meta.Get2("v") != float64(Version) || !b.Has["ui.db"] {
|
||||
return fail(errors.New("не архив переноса или от другой версии (нужны meta.json v1 и ui.db)"))
|
||||
}
|
||||
b.Meta = meta
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// PanelData — сколько в базе того, что перенос затёр бы (мастер-ключи, ноды, ключи API).
|
||||
func PanelData(file string) (int, error) {
|
||||
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
db, err := OpenDB(file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer db.Close()
|
||||
return Count(db), nil
|
||||
}
|
||||
|
||||
func Count(db *sql.DB) int {
|
||||
total := 0
|
||||
for _, t := range []string{"master_keys", "nodes", "api_keys"} {
|
||||
var n int
|
||||
// Таблицы может не быть — awg-ui ещё не запускался.
|
||||
if db.QueryRow("SELECT COUNT(*) FROM "+t).Scan(&n) == nil {
|
||||
total += n
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ── Раскладка на новой панели ──────────────────────────────────────────────
|
||||
|
||||
// Plan — порты и переменные cli.env новой панели. Подписка и хаб — на прежних портах: они в
|
||||
// ссылках, у нод и в DNS, если адрес — домен.
|
||||
type Plan struct {
|
||||
SubPort, NodePort, UIPort int
|
||||
Host string
|
||||
Env [][2]string
|
||||
}
|
||||
|
||||
// NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес.
|
||||
func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) {
|
||||
p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host}
|
||||
if p.SubPort == 0 {
|
||||
p.SubPort = env("SUB_PORT")
|
||||
}
|
||||
if p.NodePort == 0 {
|
||||
p.NodePort = env("NODE_PORT")
|
||||
}
|
||||
if p.NodePort == 0 {
|
||||
return nil, errors.New("приём нод не настроен (NODE_PORT): старый сервер подключится к панели нодой")
|
||||
}
|
||||
if p.UIPort != 0 && (p.UIPort == p.SubPort || p.UIPort == p.NodePort) || p.SubPort != 0 && p.SubPort == p.NodePort {
|
||||
return nil, fmt.Errorf("порты пересекаются: панель %d, подписка %d, хаб %d — смените UI_PORT", p.UIPort, p.SubPort, p.NodePort)
|
||||
}
|
||||
for _, kv := range [][2]string{{"SUB_PORT", strconv.Itoa(p.SubPort)}, {"NODE_PORT", strconv.Itoa(p.NodePort)}, {"SUB_HOST", host}} {
|
||||
if kv[1] != "0" && kv[1] != "" {
|
||||
p.Env = append(p.Env, kv)
|
||||
}
|
||||
}
|
||||
tlsOn := "off"
|
||||
if b.Has["sub_tls.crt"] && b.Meta.Get2("sub_tls") == true {
|
||||
tlsOn = "on"
|
||||
}
|
||||
p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn})
|
||||
// С этого момента панель считает, кто из клиентов и нод узнал её новый адрес.
|
||||
p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)})
|
||||
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
|
||||
if ip := b.Str("server_ip"); ip != "" {
|
||||
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// TrustRelay — ещё один адрес ретранслятора: старый сервер может выходить в сеть не с того IP,
|
||||
// что у него в SERVER_IP (NAT, второй адрес). Панель видит, откуда пришёл архив, — его и берём.
|
||||
func (p *Plan) TrustRelay(ip string) {
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
for i, kv := range p.Env {
|
||||
if kv[0] == "RELAY_FROM" {
|
||||
for _, have := range strings.Split(kv[1], ",") {
|
||||
if have == ip {
|
||||
return
|
||||
}
|
||||
}
|
||||
p.Env[i][1] += "," + ip
|
||||
return
|
||||
}
|
||||
}
|
||||
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
|
||||
}
|
||||
|
||||
// Install — файлы архива на свои места; прежние уходят в *.bak-<время>.
|
||||
func Install(b *Bundle, p Paths) error {
|
||||
stamp := time.Now().Format("20060102-150405")
|
||||
for _, f := range Files {
|
||||
if !b.Has[f.Name] {
|
||||
continue
|
||||
}
|
||||
dst := p[f.Name]
|
||||
olds := []string{dst}
|
||||
if f.Name == "ui.db" {
|
||||
olds = append(olds, dst+"-wal", dst+"-shm")
|
||||
}
|
||||
for _, o := range olds {
|
||||
if _, err := os.Stat(o); err == nil {
|
||||
if err := os.Rename(o, o+".bak-"+stamp); err != nil {
|
||||
return fmt.Errorf("не удалось отложить %s: %w", o, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(b.Dir, f.Name))
|
||||
if err == nil {
|
||||
err = os.MkdirAll(filepath.Dir(dst), 0o700)
|
||||
}
|
||||
if err == nil {
|
||||
err = os.WriteFile(dst, raw, 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось записать %s: %w", dst, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNodeZero — старый сервер в реестре нод под прежним id 0, ждёт первого подключения.
|
||||
func AddNodeZero(dbFile, name string, secret []byte) error {
|
||||
if name == "" || len([]rune(name)) > 40 || strings.ContainsAny(name, "\r\n<>") {
|
||||
name = "VPN"
|
||||
}
|
||||
db, err := OpenDB(dbFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
h := sha256.Sum256(secret)
|
||||
now := time.Now().Unix()
|
||||
if _, err := db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, ?, ?, ?, ?)",
|
||||
name, h[:], now+JoinTTL, now); err != nil {
|
||||
return fmt.Errorf("нода #0 не заведена (база со старой версии? обновите старый сервер): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CertPin — SHA-256 SPKI сертификата, base64url: то же, что pin в join-строке.
|
||||
func CertPin(file string) (string, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blk, _ := pem.Decode(raw)
|
||||
if blk == nil {
|
||||
return "", errors.New("не PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(blk.Bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
||||
return b64url.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// JoinString — как у панели (nodes.go), плюс ui/sub: куда ретранслятору старого сервера слать
|
||||
// прежние порты панели и подписки. Остальные читатели строки лишних полей не видят.
|
||||
func JoinString(host string, port int, pin string, id int64, secret []byte, uiPort, subPort int) string {
|
||||
o := jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: port},
|
||||
{Key: "pin", Val: pin}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}
|
||||
if uiPort > 0 {
|
||||
o = append(o, jsjson.KV{Key: "ui", Val: uiPort})
|
||||
}
|
||||
if subPort > 0 {
|
||||
o = append(o, jsjson.KV{Key: "sub", Val: subPort})
|
||||
}
|
||||
return "awgjoin://" + b64url.EncodeToString(jsjson.MustMarshal(o))
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T, file string) {
|
||||
t.Helper()
|
||||
db, err := OpenDB(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, q := range []string{
|
||||
"CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
||||
"INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')",
|
||||
"CREATE TABLE nodes (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pub TEXT, join_hash BLOB, join_expires INTEGER, created_at INTEGER NOT NULL, last_seen INTEGER, version TEXT)",
|
||||
"CREATE TABLE master_keys (id INTEGER PRIMARY KEY)",
|
||||
"INSERT INTO master_keys VALUES (1)",
|
||||
} {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Архив → раскладка: всё, что зашито в ссылки и у нод, едет как есть; база — целостным
|
||||
// снимком, в котором новая панель заводит ноду #0.
|
||||
func TestBundleRoundTrip(t *testing.T) {
|
||||
src, dst := t.TempDir(), t.TempDir()
|
||||
paths := func(dir string) Paths {
|
||||
return PathsFrom(func(env string) string { return filepath.Join(dir, strings.ToLower(env)) })
|
||||
}
|
||||
from, to := paths(src), paths(dst)
|
||||
testDB(t, from["ui.db"])
|
||||
for name, v := range map[string]string{"sub_sign.key": "sign", "sub_tls.key": "tlskey", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
|
||||
os.WriteFile(from[name], []byte(v), 0o600)
|
||||
}
|
||||
os.WriteFile(to["sub_sign.key"], []byte("прежний"), 0o600)
|
||||
|
||||
db, _ := OpenDB(from["ui.db"])
|
||||
var buf bytes.Buffer
|
||||
err := Write(&buf, db, from, jsjson.Object{{Key: "server_ip", Val: "198.51.100.1"}, {Key: "sub_port", Val: 40123},
|
||||
{Key: "sub_tls", Val: true}, {Key: "node_port", Val: 40500}})
|
||||
db.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := Read(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
if b.Str("panel_id") != "abcdef123456" || b.Num("sub_port") != 40123 || b.Has["node_tls.crt"] {
|
||||
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
|
||||
}
|
||||
if n, err := PanelData(to["ui.db"]); err != nil || n != 0 {
|
||||
t.Errorf("PanelData(нет файла) = %d, %v", n, err)
|
||||
}
|
||||
|
||||
plan, err := NewPlan(b, func(string) int { return 0 }, 33000, "203.0.113.7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := regexp.MustCompile(`"MOVED_IN_AT","\d+"`).ReplaceAllString(string(jsjson.MustMarshal(plan.Env)), `"MOVED_IN_AT","…"`)
|
||||
if got != `[["SUB_PORT","40123"],["NODE_PORT","40500"],["SUB_HOST","203.0.113.7"],["SUB_TLS","on"],["MOVED_IN_AT","…"],["RELAY_FROM","198.51.100.1"]]` {
|
||||
t.Errorf("env: %s", got)
|
||||
}
|
||||
if _, err := NewPlan(b, func(string) int { return 0 }, 40500, "h"); err == nil {
|
||||
t.Error("UI_PORT = порт хаба: ожидали ошибку")
|
||||
}
|
||||
|
||||
if err := Install(b, to); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string]string{"sub_sign.key": "sign", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
|
||||
if raw, _ := os.ReadFile(to[name]); string(raw) != want {
|
||||
t.Errorf("%s: %q", name, raw)
|
||||
}
|
||||
}
|
||||
if baks, _ := filepath.Glob(to["sub_sign.key"] + ".bak-*"); len(baks) != 1 {
|
||||
t.Errorf("прежний ключ не отложен: %v", baks)
|
||||
}
|
||||
if n, _ := PanelData(to["ui.db"]); n != 1 {
|
||||
t.Errorf("PanelData(снимок) = %d", n)
|
||||
}
|
||||
if err := AddNodeZero(to["ui.db"], "VPN\n<script>", []byte("0123456789abcdef")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, _ = OpenDB(to["ui.db"])
|
||||
var name string
|
||||
db.QueryRow("SELECT name FROM nodes WHERE id = 0").Scan(&name)
|
||||
db.Close()
|
||||
if name != "VPN" {
|
||||
t.Errorf("нода #0: %q", name)
|
||||
}
|
||||
if err := AddNodeZero(to["ui.db"], "VPN", []byte("x")); err == nil {
|
||||
t.Error("вторая нода #0: ожидали ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsForeignEntries(t *testing.T) {
|
||||
for name, entries := range map[string][]string{
|
||||
"лишний файл": {"meta.json", "ui.db", "../../etc/passwd"},
|
||||
"без базы": {"meta.json"},
|
||||
"повтор": {"meta.json", "ui.db", "ui.db"},
|
||||
} {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for _, e := range entries {
|
||||
data := []byte("x")
|
||||
if e == "meta.json" {
|
||||
data = []byte(`{"v":1}`)
|
||||
}
|
||||
tw.WriteHeader(&tar.Header{Name: e, Mode: 0o600, Size: int64(len(data))})
|
||||
tw.Write(data)
|
||||
}
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
if b, err := Read(&buf); err == nil {
|
||||
b.Close()
|
||||
t.Errorf("%s: архив принят", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinString(t *testing.T) {
|
||||
s := JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123)
|
||||
raw, _ := b64url.DecodeString(strings.TrimPrefix(s, "awgjoin://"))
|
||||
// Поля сверх v1 — в конце: install.sh и старые CLI их не замечают.
|
||||
if want := `{"v":1,"h":"203.0.113.7","p":40500,"pin":"` + strings.Repeat("A", 43) + `","n":0,"s":"MDEyMzQ1Njc4OWFiY2RlZg","ui":33100,"sub":40123}`; string(raw) != want {
|
||||
t.Errorf("join: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeAndSeal(t *testing.T) {
|
||||
c := Code{Host: "2001:db8::7", Port: 33000, Key: NewKey()}
|
||||
got, err := ParseCode(" " + c.String() + "\n")
|
||||
if err != nil || got.Host != c.Host || got.Port != c.Port || !bytes.Equal(got.Key, c.Key) {
|
||||
t.Fatalf("ParseCode: %+v %v", got, err)
|
||||
}
|
||||
if u := got.URL(); u != "http://[2001:db8::7]:33000/migrate/v1/import" {
|
||||
t.Errorf("URL: %s", u)
|
||||
}
|
||||
for _, bad := range []string{"", "awgmove://", "awgjoin://e30", CodePrefix + "e30",
|
||||
Code{Host: "h/../x", Port: 1, Key: c.Key}.String(), Code{Host: "h", Port: 0, Key: c.Key}.String(), Code{Host: "h", Port: 1, Key: []byte("short")}.String()} {
|
||||
if _, err := ParseCode(bad); err == nil {
|
||||
t.Errorf("ParseCode(%q): ожидали ошибку", bad)
|
||||
}
|
||||
}
|
||||
|
||||
box := SealRequest(c.Key, []byte("архив"))
|
||||
if plain, err := OpenRequest(c.Key, box); err != nil || string(plain) != "архив" {
|
||||
t.Errorf("OpenRequest: %q %v", plain, err)
|
||||
}
|
||||
if _, err := OpenResponse(c.Key, box); err == nil {
|
||||
t.Error("запрос открылся как ответ")
|
||||
}
|
||||
if _, err := OpenRequest(NewKey(), box); err == nil {
|
||||
t.Error("открылся чужим ключом")
|
||||
}
|
||||
box[len(box)-1] ^= 1
|
||||
if _, err := OpenRequest(c.Key, box); err == nil {
|
||||
t.Error("открылся повреждённый")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package migrate
|
||||
|
||||
// Перенос из панели в панель (вкладка «Перенос»): новая панель выдаёт одноразовый код
|
||||
// awgmove://, старая шлёт по нему архив на её порт панели. Порт панели обычно — голый HTTP,
|
||||
// а в архиве ключ подписи, поэтому и запрос, и ответ зашифрованы AES-256-GCM ключом из кода:
|
||||
// без кода архив не прочесть и не подделать, а верный тег и есть доказательство, что код знают.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
const (
|
||||
CodePrefix = "awgmove://"
|
||||
keyLen = 32
|
||||
// Разные AAD — ответ нельзя выдать за запрос и наоборот.
|
||||
aadRequest = "awgmove-v1 request"
|
||||
aadResponse = "awgmove-v1 response"
|
||||
)
|
||||
|
||||
// Code — что старая панель узнаёт из кода: куда слать и чем шифровать.
|
||||
type Code struct {
|
||||
Host string
|
||||
Port int
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func NewKey() []byte {
|
||||
k := make([]byte, keyLen)
|
||||
rand.Read(k)
|
||||
return k
|
||||
}
|
||||
|
||||
func (c Code) String() string {
|
||||
return CodePrefix + b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{
|
||||
{Key: "v", Val: Version}, {Key: "h", Val: c.Host}, {Key: "p", Val: c.Port}, {Key: "k", Val: b64url.EncodeToString(c.Key)}}))
|
||||
}
|
||||
|
||||
func ParseCode(s string) (Code, error) {
|
||||
bad := errors.New("код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели")
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, CodePrefix) {
|
||||
return Code{}, bad
|
||||
}
|
||||
raw, err := b64url.DecodeString(s[len(CodePrefix):])
|
||||
if err != nil {
|
||||
return Code{}, bad
|
||||
}
|
||||
v, err := jsjson.Decode(raw)
|
||||
o, ok := v.(jsjson.Object)
|
||||
if err != nil || !ok || o.Get2("v") != float64(Version) {
|
||||
return Code{}, bad
|
||||
}
|
||||
host, _ := o.Get2("h").(string)
|
||||
port, _ := o.Get2("p").(float64)
|
||||
k, _ := o.Get2("k").(string)
|
||||
key, err := b64url.DecodeString(k)
|
||||
if host == "" || len(host) > 253 || strings.ContainsAny(host, "/?#@ ") || port <= 0 || port > 65535 ||
|
||||
port != float64(int(port)) || err != nil || len(key) != keyLen {
|
||||
return Code{}, bad
|
||||
}
|
||||
return Code{Host: host, Port: int(port), Key: key}, nil
|
||||
}
|
||||
|
||||
// URL — куда слать архив.
|
||||
func (c Code) URL() string {
|
||||
h := c.Host
|
||||
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
|
||||
h = "[" + h + "]"
|
||||
}
|
||||
return "http://" + h + ":" + strconv.Itoa(c.Port) + "/migrate/v1/import"
|
||||
}
|
||||
|
||||
func gcm(key []byte) (cipher.AEAD, error) {
|
||||
blk, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(blk)
|
||||
}
|
||||
|
||||
func seal(key []byte, aad string, plain []byte) []byte {
|
||||
g, err := gcm(key)
|
||||
if err != nil {
|
||||
panic(err) // ключ всегда 32 байта
|
||||
}
|
||||
nonce := make([]byte, g.NonceSize())
|
||||
rand.Read(nonce)
|
||||
return g.Seal(nonce, nonce, plain, []byte(aad))
|
||||
}
|
||||
|
||||
var ErrSeal = errors.New("не расшифровать: код не тот или данные повреждены")
|
||||
|
||||
func open(key []byte, aad string, box []byte) ([]byte, error) {
|
||||
g, err := gcm(key)
|
||||
if err != nil || len(box) < g.NonceSize() {
|
||||
return nil, ErrSeal
|
||||
}
|
||||
plain, err := g.Open(nil, box[:g.NonceSize()], box[g.NonceSize():], []byte(aad))
|
||||
if err != nil {
|
||||
return nil, ErrSeal
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func SealRequest(key, plain []byte) []byte { return seal(key, aadRequest, plain) }
|
||||
func OpenRequest(key, box []byte) ([]byte, error) { return open(key, aadRequest, box) }
|
||||
func SealResponse(key, plain []byte) []byte { return seal(key, aadResponse, plain) }
|
||||
func OpenResponse(key, box []byte) ([]byte, error) { return open(key, aadResponse, box) }
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package relay — ретранслятор TCP на сервере, с которого переехала панель (`awg-control
|
||||
// migrate`). В выданных ссылках sen://, у подключённых нод и у клиентов внешнего API остался
|
||||
// старый адрес: relay слушает прежние порты подписки, хаба нод и панели и пересылает соединения
|
||||
// на новую панель как есть — TLS идёт насквозь, ключи и сертификаты у панели прежние. Перед
|
||||
// данными идёт заголовок PROXY v1 с адресом клиента: панель верит ему только от RELAY_FROM.
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
// Route — одна пересылка: локальный порт → адрес новой панели.
|
||||
type Route struct {
|
||||
Listen int
|
||||
Target string // host:port
|
||||
}
|
||||
|
||||
// ParseRoutes — RELAY: «порт=хост:порт» через запятую.
|
||||
func ParseRoutes(s string) ([]Route, error) {
|
||||
var out []Route
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
l, t, ok := strings.Cut(part, "=")
|
||||
port, err := strconv.Atoi(l)
|
||||
if !ok || err != nil || port <= 0 || port > 65535 {
|
||||
return nil, fmt.Errorf("relay: неверная пересылка %q (нужно порт=хост:порт)", part)
|
||||
}
|
||||
host, tp, err := net.SplitHostPort(t)
|
||||
if n, perr := strconv.Atoi(tp); err != nil || host == "" || perr != nil || n <= 0 || n > 65535 {
|
||||
return nil, fmt.Errorf("relay: неверный адрес %q", t)
|
||||
}
|
||||
out = append(out, Route{port, t})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("relay: RELAY пуст — пересылать нечего")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// proxyHeader — строка PROXY v1 для соединения c.
|
||||
func proxyHeader(c net.Conn) string {
|
||||
src, ok1 := c.RemoteAddr().(*net.TCPAddr)
|
||||
dst, ok2 := c.LocalAddr().(*net.TCPAddr)
|
||||
if !ok1 || !ok2 {
|
||||
return "PROXY UNKNOWN\r\n"
|
||||
}
|
||||
sip, dip := src.IP, dst.IP
|
||||
fam := "TCP6"
|
||||
if s4, d4 := sip.To4(), dip.To4(); s4 != nil && d4 != nil {
|
||||
fam, sip, dip = "TCP4", s4, d4
|
||||
} else if s4 != nil || d4 != nil {
|
||||
// Смешанные семейства (v4-mapped на v6-сокете и т. п.) — оба в v6.
|
||||
sip, dip = sip.To16(), dip.To16()
|
||||
}
|
||||
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, sip, dip, src.Port, dst.Port)
|
||||
}
|
||||
|
||||
// pipe — копирование в обе стороны; конец одной стороны закрывает запись в другую.
|
||||
func pipe(a, b net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
cp := func(dst, src net.Conn) {
|
||||
defer wg.Done()
|
||||
io.Copy(dst, src)
|
||||
if tc, ok := dst.(*net.TCPConn); ok {
|
||||
tc.CloseWrite()
|
||||
} else {
|
||||
dst.Close()
|
||||
}
|
||||
}
|
||||
wg.Add(2)
|
||||
go cp(a, b)
|
||||
go cp(b, a)
|
||||
wg.Wait()
|
||||
a.Close()
|
||||
b.Close()
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, ln net.Listener, r Route, errorf func(string, ...any)) {
|
||||
var d net.Dialer
|
||||
d.Timeout = dialTimeout
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
errorf("relay: :%d: %v", r.Listen, err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
up, err := d.DialContext(ctx, "tcp", r.Target)
|
||||
if err != nil {
|
||||
errorf("relay: :%d → %s: %v", r.Listen, r.Target, err)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
if _, err := io.WriteString(up, proxyHeader(c)); err != nil {
|
||||
c.Close()
|
||||
up.Close()
|
||||
return
|
||||
}
|
||||
pipe(c, up)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Run слушает все порты и пересылает до отмены ctx. Порт, который не открылся, — ошибка сразу:
|
||||
// значит, его ещё держит прежняя панель.
|
||||
func Run(ctx context.Context, routes []Route, logf, errorf func(string, ...any)) error {
|
||||
var lns []net.Listener
|
||||
for _, r := range routes {
|
||||
ln, err := net.Listen("tcp", ":"+strconv.Itoa(r.Listen))
|
||||
if err != nil {
|
||||
for _, l := range lns {
|
||||
l.Close()
|
||||
}
|
||||
return fmt.Errorf("relay: порт %d: %w", r.Listen, err)
|
||||
}
|
||||
lns = append(lns, ln)
|
||||
logf("relay: :%d → %s", r.Listen, r.Target)
|
||||
}
|
||||
for i, r := range routes {
|
||||
go serve(ctx, lns[i], r, errorf)
|
||||
}
|
||||
<-ctx.Done()
|
||||
for _, l := range lns {
|
||||
l.Close()
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Main — роль `awg-control relay`: пересылки из RELAY.
|
||||
func Main(ctx context.Context) error {
|
||||
routes, err := ParseRoutes(os.Getenv("RELAY"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logf := func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
|
||||
errorf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
|
||||
return Run(ctx, routes, logf, errorf)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseRoutes(t *testing.T) {
|
||||
r, err := ParseRoutes(" 40123=203.0.113.7:40123, 8443=[2001:db8::1]:9000 ,")
|
||||
if err != nil || len(r) != 2 || r[0] != (Route{40123, "203.0.113.7:40123"}) || r[1] != (Route{8443, "[2001:db8::1]:9000"}) {
|
||||
t.Fatalf("ParseRoutes: %v %v", r, err)
|
||||
}
|
||||
for _, bad := range []string{"", ",", "40123", "0=h:1", "70000=h:1", "1=h", "1=:5", "1=h:0", "x=h:1"} {
|
||||
if _, err := ParseRoutes(bad); err == nil {
|
||||
t.Errorf("ParseRoutes(%q): ожидали ошибку", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
return ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// Поток идёт насквозь в обе стороны, первой строкой у панели — PROXY с адресом клиента.
|
||||
func TestRelayForwardsWithProxyHeader(t *testing.T) {
|
||||
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer target.Close()
|
||||
got := make(chan string, 2)
|
||||
go func() {
|
||||
c, err := target.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
r := bufio.NewReader(c)
|
||||
hdr, _ := r.ReadString('\n')
|
||||
got <- hdr
|
||||
body, _ := io.ReadAll(r)
|
||||
got <- string(body)
|
||||
io.WriteString(c, "pong")
|
||||
}()
|
||||
|
||||
port := freePort(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
errs <- Run(ctx, []Route{{port, target.Addr().String()}}, func(string, ...any) {}, func(f string, a ...any) { t.Logf(f, a...) })
|
||||
}()
|
||||
|
||||
var cl net.Conn
|
||||
for range 50 {
|
||||
if cl, err = net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cl.Close()
|
||||
io.WriteString(cl, "ping")
|
||||
cl.(*net.TCPConn).CloseWrite()
|
||||
|
||||
hdr := <-got
|
||||
src := cl.LocalAddr().(*net.TCPAddr)
|
||||
if want := "PROXY TCP4 127.0.0.1 127.0.0.1 " + strconv.Itoa(src.Port) + " " + strconv.Itoa(port) + "\r\n"; hdr != want {
|
||||
t.Errorf("заголовок %q, ожидали %q", hdr, want)
|
||||
}
|
||||
if body := <-got; body != "ping" {
|
||||
t.Errorf("до панели дошло %q", body)
|
||||
}
|
||||
cl.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if back, _ := io.ReadAll(cl); string(back) != "pong" {
|
||||
t.Errorf("обратно пришло %q", back)
|
||||
}
|
||||
|
||||
// Занятый порт — ошибка сразу: значит, его держит прежняя панель.
|
||||
busy, _ := net.Listen("tcp", ":0")
|
||||
defer busy.Close()
|
||||
if err := Run(context.Background(), []Route{{busy.Addr().(*net.TCPAddr).Port, "127.0.0.1:1"}}, func(string, ...any) {}, t.Logf); err == nil ||
|
||||
!strings.Contains(err.Error(), "порт") {
|
||||
t.Errorf("занятый порт: %v", err)
|
||||
}
|
||||
cancel()
|
||||
<-errs
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
// «Серверы для всех»: add — у каждого живого ключа, remove — ни у кого, остальное не трогается;
|
||||
// пиры снятых серверов — в надгробия, новые ключи получают набор по умолчанию.
|
||||
func TestBulkServers(t *testing.T) {
|
||||
p := newMovePanel(t, "off", "40500")
|
||||
s, db := p.s, p.a.db
|
||||
if err := p.a.initAPIKeys(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, q := range []string{
|
||||
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'A', 'x', 1), (2, 'B', 'x', 1), (3, 'C', 'x', 1)",
|
||||
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k1', x'01', 1), (2, 'u2', 'k2', x'02', 1), (3, 'u3', 'k3', x'03', 1)",
|
||||
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (4, 'u4', 'k4', x'04', 1, 1)",
|
||||
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 1), (2, 1), (2, 2), (3, 2), (4, 1)",
|
||||
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 2, 'd', 'W', 'A', 1)",
|
||||
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 1, 'P1', '10.0.0.2', 'K'), (5, 2, 'P2', '10.0.1.2', 'K')",
|
||||
"INSERT INTO api_keys (id, label, key_hash, prefix, created_at) VALUES (1, 'Бот', 'h1', 'p1', 1), (2, 'Всё', 'h2', 'p2', 1)",
|
||||
"INSERT INTO api_key_servers (key_id, server_id) VALUES (1, 1), (1, 2), (2, 1), (2, 2), (2, 3)",
|
||||
} {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatal(q, err)
|
||||
}
|
||||
}
|
||||
servers := func() string {
|
||||
var out []string
|
||||
for _, id := range []int64{1, 2, 3, 4} {
|
||||
out = append(out, string(jsjson.MustMarshal(intsJSON(s.serverIDsOf(db, id)))))
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
body := func(kv ...any) jsjson.Object {
|
||||
o := jsjson.Object{}
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
o = append(o, jsjson.KV{Key: kv[i].(string), Val: kv[i+1]})
|
||||
}
|
||||
return o
|
||||
}
|
||||
f := func(ids ...float64) []any {
|
||||
out := []any{}
|
||||
for _, x := range ids {
|
||||
out = append(out, x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
status := func(err error) int {
|
||||
var ae *apiError
|
||||
if errors.As(err, &ae) {
|
||||
return ae.status
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
out, err := s.bulkServers(body("add", f(3), "remove", f(2), "dry_run", true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(jsjson.MustMarshal(out)); got != `{"changed":3,"total":3,"hidden":[{"id":1,"label":"Бот","keys":3}]}` {
|
||||
t.Errorf("предпросмотр: %s", got)
|
||||
}
|
||||
if got := servers(); got != "[1] [1,2] [2] [1]" {
|
||||
t.Errorf("предпросмотр изменил ключи: %s", got)
|
||||
}
|
||||
if _, err := s.bulkServers(body("remove", f(1, 2))); status(err) != 409 {
|
||||
t.Errorf("ключи без серверов: %v", err)
|
||||
}
|
||||
if _, err := s.bulkServers(body("add", f(2), "remove", f(2))); status(err) != 400 {
|
||||
t.Errorf("и у всех, и ни у кого: %v", err)
|
||||
}
|
||||
if _, err := s.bulkServers(body("add", f(9))); status(err) != 400 {
|
||||
t.Errorf("несуществующий сервер: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.bulkServers(body("add", f(3), "remove", f(2), "defaults", f(3))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := servers(); got != "[1,3] [1,3] [3] [1]" {
|
||||
t.Errorf("после применения: %s (удаляемый ключ трогать нельзя)", got)
|
||||
}
|
||||
var n int
|
||||
db.QueryRow("SELECT COUNT(*) FROM peer_tombstones WHERE server_id = 2 AND pub_key = 'P2'").Scan(&n)
|
||||
if n != 1 {
|
||||
t.Error("пир снятого сервера не в надгробиях")
|
||||
}
|
||||
db.QueryRow("SELECT COUNT(*) FROM device_peers WHERE device_id = 5 AND server_id = 3").Scan(&n)
|
||||
if n != 1 {
|
||||
t.Error("устройство не получило заявку на новом сервере")
|
||||
}
|
||||
m, err := s.createMaster(body("label", "Новый"), nil)
|
||||
if err != nil || string(jsjson.MustMarshal(m.Get2("servers"))) != "[3]" {
|
||||
t.Errorf("новый ключ: %v %v", m, err)
|
||||
}
|
||||
if _, err := s.bulkServers(body("defaults", nil)); err != nil || s.defaultServers() != nil {
|
||||
t.Errorf("сброс набора по умолчанию: %v %v", s.defaultServers(), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package ui
|
||||
|
||||
// Вкладка «Перенос»: панель переезжает с сервера «панель + VPN» на отдельную панель без VPN
|
||||
// прямо из интерфейса, без архива в руках. Те же шаги, что `awg-control migrate` (общий код —
|
||||
// internal/migrate), только архив идёт из панели в панель:
|
||||
//
|
||||
// новая: POST /ui/migrate/code → одноразовый код awgmove:// (адрес, порт панели, ключ)
|
||||
// старая: POST /ui/migrate/export → замораживает /sub/v1, шлёт зашифрованный архив на
|
||||
// POST /migrate/v1/import новой панели
|
||||
// новая: раскладывает архив, заводит ноду #0, отвечает join-строкой и перезапускается
|
||||
// старая: пишет PANEL_MOVED и запускает `awg-control join` — становится нодой #0 с ретранслятором
|
||||
//
|
||||
// Перезапуск и join делает CLI в отдельном процессе: панель останавливает сама себя.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/envfile"
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
"github.com/maeneko/forgetting/internal/migrate"
|
||||
)
|
||||
|
||||
const (
|
||||
moveCodeTTL = time.Hour
|
||||
moveMaxFails = 5
|
||||
moveMaxBundle = 1 << 30
|
||||
moveTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Состояние переноса на этой панели (в памяти: перезапуск его сбрасывает, и так и надо).
|
||||
const (
|
||||
moveIdle = ""
|
||||
moveSending = "sending" // старая: архив в пути, /sub/v1 заморожен
|
||||
moveMoved = "moved" // старая: новая приняла, сейчас будет join
|
||||
moveUncertain = "uncertain" // старая: архив ушёл, а ответа нет — новая могла его принять
|
||||
moveImported = "imported" // новая: архив принят, сейчас перезапуск
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
a *App
|
||||
envFile string // cli.env: сюда пишутся порты, PANEL_MOVED, RELAY_FROM
|
||||
run func(args ...string) // `awg-control …` отдельным процессом; в тестах — заглушка
|
||||
|
||||
mu sync.Mutex
|
||||
key []byte // новая: ключ действующего кода
|
||||
host string
|
||||
expires time.Time
|
||||
fails int
|
||||
state string
|
||||
errText string
|
||||
target string // старая: адрес новой панели
|
||||
}
|
||||
|
||||
func newMigration(a *App) *Migration {
|
||||
m := &Migration{a: a, envFile: os.Getenv("CLI_ENV_FILE")}
|
||||
m.run = m.spawn
|
||||
return m
|
||||
}
|
||||
|
||||
func (a *App) filePath(env, name string) string {
|
||||
if v := os.Getenv(env); v != "" {
|
||||
return v
|
||||
}
|
||||
return filepath.Join(a.baseDir, name)
|
||||
}
|
||||
|
||||
// paths — где awg-ui держит файлы из архива (те же правила, что при их загрузке).
|
||||
func (m *Migration) paths() migrate.Paths {
|
||||
return migrate.PathsFrom(func(env string) string {
|
||||
for _, f := range migrate.Files {
|
||||
if f.Env == env {
|
||||
return m.a.filePath(env, f.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
// canExport / canAccept — "" или причина, почему нельзя.
|
||||
func (m *Migration) canExport() string {
|
||||
switch {
|
||||
case m.envFile == "":
|
||||
return "Панель запущена не через awg-control — перенос недоступен"
|
||||
case os.Getenv("CORE_HOST") != "":
|
||||
return "Этот сервер уже нода другой панели"
|
||||
case !m.a.nodes.local:
|
||||
return "У этой панели нет своего VPN: переносить её как сервер-ноду нечего"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Migration) canAccept() string {
|
||||
switch {
|
||||
case m.envFile == "":
|
||||
return "Панель запущена не через awg-control — перенос недоступен"
|
||||
case m.a.nodes.local:
|
||||
return "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
|
||||
case !m.a.nodes.hubReady():
|
||||
return "Не настроен приём нод (NODE_PORT): старый сервер подключится сюда нодой"
|
||||
case migrate.Count(m.a.db) > 0:
|
||||
return "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Migration) router() *Router {
|
||||
rt := newRouter()
|
||||
rt.Get("/", m.status)
|
||||
rt.Post("/code", m.newCode)
|
||||
rt.Delete("/code", func(c *Ctx) bool {
|
||||
m.mu.Lock()
|
||||
m.key = nil
|
||||
m.mu.Unlock()
|
||||
return m.status(c)
|
||||
})
|
||||
rt.Post("/export", m.export)
|
||||
rt.Post("/cancel", m.cancel)
|
||||
rt.Delete("/retire", func(c *Ctx) bool {
|
||||
if err := envfile.Set(m.envFile, nil, "MOVED_IN_AT"); err != nil {
|
||||
return c.Error(500, err.Error())
|
||||
}
|
||||
os.Unsetenv("MOVED_IN_AT")
|
||||
return m.status(c)
|
||||
})
|
||||
return rt
|
||||
}
|
||||
|
||||
func (m *Migration) status(c *Ctx) bool {
|
||||
m.mu.Lock()
|
||||
state, errText, target := m.state, m.errText, m.target
|
||||
var code any
|
||||
if m.key != nil && time.Now().Before(m.expires) {
|
||||
code = jsjson.Object{{Key: "code", Val: migrate.Code{Host: m.host, Port: m.a.port, Key: m.key}.String()},
|
||||
{Key: "expires_in", Val: int(time.Until(m.expires).Seconds())}}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
o := jsjson.Object{{Key: "state", Val: state}, {Key: "error", Val: orNil(errText)}, {Key: "target", Val: orNil(target)}, {Key: "code", Val: code}}
|
||||
// После приёма база закрыта до перезапуска — что можно, уже не считаем.
|
||||
if state != moveImported {
|
||||
o = append(o, jsjson.KV{Key: "export", Val: orNil(m.canExport())}, jsjson.KV{Key: "accept", Val: orNil(m.canAccept())},
|
||||
jsjson.KV{Key: "retire", Val: m.retire()})
|
||||
}
|
||||
return c.JSON(200, o)
|
||||
}
|
||||
|
||||
// ── Новая панель ────────────────────────────────────────────────────────────
|
||||
|
||||
// retire — можно ли выключить старый сервер (ноду #0 с ретранслятором): кто из клиентов и нод
|
||||
// уже знает новый адрес панели. Клиент узнаёт его из endpoints любого конфига после переноса,
|
||||
// нода — из ready (core_ok). Ссылки sen:// без устройств ещё никем не открыты: в них старый
|
||||
// адрес, их выдают заново. nil — панель ничего не принимала (или отметку убрали).
|
||||
func (m *Migration) retire() any {
|
||||
since, _ := strconv.ParseInt(os.Getenv("MOVED_IN_AT"), 10, 64)
|
||||
if since == 0 || m.a.nodes.local {
|
||||
return nil
|
||||
}
|
||||
home := m.a.nodes.home()
|
||||
var total, fresh, unused int
|
||||
m.a.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(d.last_seen >= ?), 0) FROM devices d
|
||||
JOIN master_keys k ON k.id = d.master_id WHERE k.revoked_at IS NULL`, since).Scan(&total, &fresh)
|
||||
m.a.db.QueryRow(`SELECT COUNT(*) FROM master_keys k WHERE k.revoked_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM devices d WHERE d.master_id = k.id)`).Scan(&unused)
|
||||
nodes := []any{}
|
||||
if rows, err := m.a.db.Query("SELECT id, name, core_ack FROM nodes WHERE id != 0 AND pub IS NOT NULL ORDER BY id"); err == nil {
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
var ack sql.NullString
|
||||
if rows.Scan(&id, &name, &ack) == nil {
|
||||
nodes = append(nodes, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name},
|
||||
{Key: "online", Val: m.a.nodes.IsOnline(int(id))}, {Key: "ok", Val: home != "" && ack.String == home}})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return jsjson.Object{{Key: "since", Val: since}, {Key: "home", Val: orNil(home)},
|
||||
{Key: "old", Val: jsjson.Object{{Key: "exists", Val: m.a.nodes.Exists(0)}, {Key: "online", Val: m.a.nodes.IsOnline(0)}}},
|
||||
{Key: "devices", Val: jsjson.Object{{Key: "total", Val: total}, {Key: "fresh", Val: fresh}}},
|
||||
{Key: "unused_links", Val: unused}, {Key: "nodes", Val: nodes}}
|
||||
}
|
||||
|
||||
func (m *Migration) newCode(c *Ctx) bool {
|
||||
if why := m.canAccept(); why != "" {
|
||||
return c.Error(409, why)
|
||||
}
|
||||
host, _ := c.Obj().Get2("host").(string)
|
||||
if h := strings.TrimSpace(os.Getenv("SUB_HOST")); h != "" {
|
||||
host = h
|
||||
}
|
||||
if !reHost.MatchString(host) {
|
||||
return c.Error(400, "Не удалось определить адрес панели — задайте SUB_HOST")
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.key, m.host, m.expires, m.fails = migrate.NewKey(), host, time.Now().Add(moveCodeTTL), 0
|
||||
m.mu.Unlock()
|
||||
logf("migrate: выдан код переноса (действует час)")
|
||||
return m.status(c)
|
||||
}
|
||||
|
||||
// importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM).
|
||||
// Ответ с ошибкой — открытым текстом и только пока ничего не тронуто.
|
||||
func (m *Migration) importHandler(c *Ctx) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.key == nil || time.Now().After(m.expires) {
|
||||
return c.Error(404, "Новая панель не ждёт переноса: выдайте код заново")
|
||||
}
|
||||
box, err := io.ReadAll(http.MaxBytesReader(c.W, c.R.Body, moveMaxBundle))
|
||||
if err != nil {
|
||||
return c.Error(413, "Архив слишком большой")
|
||||
}
|
||||
plain, err := migrate.OpenRequest(m.key, box)
|
||||
if err != nil {
|
||||
if m.fails++; m.fails >= moveMaxFails {
|
||||
m.key = nil
|
||||
}
|
||||
warnf("migrate: архив не расшифрован ip=%s", c.remoteIP())
|
||||
return c.Error(403, "Код не подходит")
|
||||
}
|
||||
key := m.key
|
||||
m.key = nil // код одноразовый
|
||||
if why := m.canAccept(); why != "" {
|
||||
return c.Error(409, why)
|
||||
}
|
||||
b, err := migrate.Read(bytes.NewReader(plain))
|
||||
if err != nil {
|
||||
return c.Error(400, err.Error())
|
||||
}
|
||||
defer b.Close()
|
||||
host := m.host
|
||||
plan, err := migrate.NewPlan(b, func(k string) int {
|
||||
switch k {
|
||||
case "SUB_PORT":
|
||||
return m.a.sub.port
|
||||
case "NODE_PORT":
|
||||
return m.a.nodes.port
|
||||
}
|
||||
return 0
|
||||
}, m.a.port, host)
|
||||
if err != nil {
|
||||
return c.Error(409, err.Error())
|
||||
}
|
||||
if ip := net.ParseIP(c.remoteIP()); ip != nil {
|
||||
plan.TrustRelay(ip.String())
|
||||
}
|
||||
|
||||
// Дальше файлы меняются: база закрывается (WAL сливается в файл до того, как его отложат).
|
||||
paths := m.paths()
|
||||
fail := func(err error) bool {
|
||||
m.state, m.errText = moveImported, "Перенос прерван: "+err.Error()+". Прежние файлы — в *.bak-<время>."
|
||||
warnf("migrate: %v", err)
|
||||
return c.Error(500, m.errText)
|
||||
}
|
||||
m.a.db.Close()
|
||||
secret := migrate.NewKey()[:16]
|
||||
if err := migrate.Install(b, paths); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
pin, err := migrate.CertPin(paths["node_tls.crt"])
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("сертификат хаба: %w", err))
|
||||
}
|
||||
if err := envfile.Set(m.envFile, plan.Env); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
|
||||
m.state = moveImported
|
||||
logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP())
|
||||
m.run("restart")
|
||||
resp := jsjson.MustMarshal(jsjson.Object{{Key: "join", Val: join}})
|
||||
c.W.Header().Set("Content-Type", "application/octet-stream")
|
||||
c.W.WriteHeader(200)
|
||||
c.W.Write(migrate.SealResponse(key, resp))
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Старая панель ───────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Migration) export(c *Ctx) bool {
|
||||
if why := m.canExport(); why != "" {
|
||||
return c.Error(409, why)
|
||||
}
|
||||
codeStr, _ := c.Obj().Get2("code").(string)
|
||||
code, err := migrate.ParseCode(codeStr)
|
||||
if err != nil {
|
||||
return c.Error(400, err.Error())
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.state != moveIdle {
|
||||
m.mu.Unlock()
|
||||
return c.Error(409, "Перенос уже идёт")
|
||||
}
|
||||
m.state, m.errText = moveSending, ""
|
||||
m.mu.Unlock()
|
||||
end := func(state, errText string) {
|
||||
m.mu.Lock()
|
||||
m.state, m.errText = state, errText
|
||||
m.mu.Unlock()
|
||||
m.a.sub.frozen.Store(state != moveIdle)
|
||||
}
|
||||
// Пока архив в пути, подписка отвечает «недоступна» (клиенты держат туннель и повторят):
|
||||
// регистрация или смена ключа сейчас остались бы только в старой базе.
|
||||
m.a.sub.frozen.Store(true)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := migrate.Write(&buf, m.a.db, m.paths(), m.exportMeta()); err != nil {
|
||||
end(moveIdle, "")
|
||||
return c.Error(500, "Архив не собран: "+err.Error())
|
||||
}
|
||||
logf("migrate: архив %d КБ → %s", buf.Len()/1024, code.URL())
|
||||
cl := &http.Client{Timeout: moveTimeout, Transport: &http.Transport{Proxy: nil}}
|
||||
resp, err := cl.Post(code.URL(), "application/octet-stream", bytes.NewReader(migrate.SealRequest(code.Key, buf.Bytes())))
|
||||
if err != nil {
|
||||
// Не дозвонились — новая точно ничего не получила. Иначе архив мог дойти.
|
||||
var op *net.OpError
|
||||
if errors.As(err, &op) && op.Op == "dial" {
|
||||
end(moveIdle, "")
|
||||
return c.Error(502, "Новая панель не отвечает: "+err.Error())
|
||||
}
|
||||
return m.uncertain(c, end, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if resp.StatusCode != 200 {
|
||||
end(moveIdle, "")
|
||||
msg := "Новая панель отказала (" + strconv.Itoa(resp.StatusCode) + ")"
|
||||
if v, derr := jsjson.Decode(raw); derr == nil {
|
||||
if e, ok := field(v, "error").(string); ok {
|
||||
msg = "Новая панель: " + e
|
||||
}
|
||||
}
|
||||
return c.Error(502, msg)
|
||||
}
|
||||
var plain []byte
|
||||
if err == nil {
|
||||
plain, err = migrate.OpenResponse(code.Key, raw)
|
||||
}
|
||||
var join string
|
||||
if err == nil {
|
||||
v, derr := jsjson.Decode(plain)
|
||||
join, _ = field(v, "join").(string)
|
||||
if derr != nil || !strings.HasPrefix(join, "awgjoin://") {
|
||||
err = errors.New("ответ без join-строки")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return m.uncertain(c, end, err)
|
||||
}
|
||||
|
||||
if err := envfile.Set(m.envFile, [][2]string{{"PANEL_MOVED", "on"}}); err != nil {
|
||||
return m.uncertain(c, end, err)
|
||||
}
|
||||
target := strings.TrimSuffix(code.URL(), "/migrate/v1/import")
|
||||
m.mu.Lock()
|
||||
m.target = target
|
||||
m.mu.Unlock()
|
||||
end(moveMoved, "")
|
||||
logf("migrate: новая панель приняла перенос — join, этот сервер станет нодой #0")
|
||||
m.run("join", join)
|
||||
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}, {Key: "target", Val: target}})
|
||||
}
|
||||
|
||||
// uncertain — архив ушёл, а подтверждения нет. Подписка остаётся замороженной: если новая
|
||||
// панель его приняла, вторая живая копия базы разошлась бы с ней.
|
||||
func (m *Migration) uncertain(c *Ctx, end func(string, string), err error) bool {
|
||||
msg := "Неизвестно, принят ли перенос (" + err.Error() + "). Откройте новую панель: если там есть сервер #0 — " +
|
||||
"выполните здесь `awg-ctrl join` со строкой из его карточки; если нет — «Отменить перенос»."
|
||||
end(moveUncertain, msg)
|
||||
warnf("migrate: %s", msg)
|
||||
return c.Error(502, msg)
|
||||
}
|
||||
|
||||
func (m *Migration) cancel(c *Ctx) bool {
|
||||
m.mu.Lock()
|
||||
ok := m.state == moveUncertain
|
||||
if ok {
|
||||
m.state, m.errText = moveIdle, ""
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return c.Error(409, "Отменять нечего")
|
||||
}
|
||||
m.a.sub.frozen.Store(false)
|
||||
logf("migrate: перенос отменён — подписка снова работает")
|
||||
return m.status(c)
|
||||
}
|
||||
|
||||
func (m *Migration) exportMeta() jsjson.Object {
|
||||
name, ip := "VPN", os.Getenv("SERVER_IP")
|
||||
if r, err := m.a.ctrlLocal("GET", "/health", nil); err == nil {
|
||||
if s, ok := field(r.Data, "server").(string); ok && s != "" {
|
||||
name = s
|
||||
}
|
||||
if s, ok := field(r.Data, "ip").(string); ok && ip == "" {
|
||||
ip = s
|
||||
}
|
||||
}
|
||||
return jsjson.Object{
|
||||
{Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()},
|
||||
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
|
||||
{Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS},
|
||||
{Key: "node_port", Val: m.a.nodes.port},
|
||||
}
|
||||
}
|
||||
|
||||
// spawn — `awg-control <args>` отдельным процессом через секунду (ответ успеет уйти). Окружение —
|
||||
// чистое: CLI читает cli.env заново, а унаследованные значения перекрыли бы новые.
|
||||
func (m *Migration) spawn(args ...string) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
warnf("migrate: %v", err)
|
||||
return
|
||||
}
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if k == "PATH" || k == "HOME" || k == "LANG" || k == "TZ" || k == "AWG_ROOT" || strings.HasPrefix(k, "LC_") {
|
||||
env = append(env, kv)
|
||||
}
|
||||
}
|
||||
logFile := filepath.Join(filepath.Dir(m.a.baseDir), "logs", "awg-migrate.log")
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
cmd := exec.Command(exe, args...)
|
||||
cmd.Env, cmd.Dir = env, "/"
|
||||
if f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
|
||||
cmd.Stdout, cmd.Stderr = f, f
|
||||
defer f.Close()
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // переживёт эту панель
|
||||
if err := cmd.Start(); err != nil {
|
||||
warnf("migrate: awg-control %s: %v", args[0], err)
|
||||
return
|
||||
}
|
||||
cmd.Wait()
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
func testApp(t *testing.T, dir, local string) *App {
|
||||
t.Helper()
|
||||
t.Setenv("LOCAL_NODE", local)
|
||||
t.Setenv("NODE_PORT", "")
|
||||
db, err := openUIDB(filepath.Join(dir, "ui.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
a := &App{db: db, baseDir: dir}
|
||||
if a.nodes, err = newNodes(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Перенос панели (`awg-control migrate`): та же ui.db на панели без VPN, старый сервер — нода
|
||||
// #0. Клиент должен получить тот же конфиг байт в байт, вместе с rev, иначе он пересоздал бы
|
||||
// туннель, а сервис на macOS — ещё и ключ.
|
||||
func TestMigratedConfigIsByteIdentical(t *testing.T) {
|
||||
oldDir, newDir := t.TempDir(), t.TempDir()
|
||||
old := testApp(t, oldDir, "on")
|
||||
s := &Sub{a: old, host: "203.0.113.7", port: 40123}
|
||||
if err := s.initSchema(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, q := range []string{
|
||||
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
|
||||
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (1, 1)",
|
||||
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'Нода', 'pub', 1)",
|
||||
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
|
||||
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0'), (5, 1, 'WG', '10.9.0.9', 'PSK1')",
|
||||
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES
|
||||
(0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0","dns":["1.1.1.1"],"mtu":1376,"gen":"3.1","awg":{"Jc":"4"}}', 1),
|
||||
(1, '{"name":"Нода","endpoint":"192.0.2.2:47619","server_pub":"S1","gen":"2"}', 1)`,
|
||||
} {
|
||||
if _, err := old.db.Exec(q); err != nil {
|
||||
t.Fatalf("%s: %v", q, err)
|
||||
}
|
||||
}
|
||||
dev := s.deviceByID(old.db, 5)
|
||||
before := jsjson.MustMarshal(s.buildConfig(dev, nil))
|
||||
if !strings.Contains(string(before), `"id":0`) || !strings.Contains(string(before), `"id":1`) {
|
||||
t.Fatalf("в конфиге до переноса нет обоих серверов: %s", before)
|
||||
}
|
||||
|
||||
if _, err := old.db.Exec("VACUUM INTO ?", filepath.Join(newDir, "ui.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Как migrate import: нода #0 ещё не подключалась.
|
||||
pre := testApp(t, newDir, "off")
|
||||
if _, err := pre.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, 'VPN', x'00', 9999999999, 1)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := testApp(t, newDir, "off")
|
||||
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
|
||||
if err := s2.initSchema(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s2.panelID != s.panelID {
|
||||
t.Errorf("panel_id: %s → %s — пиры на старом сервере остались бы без хозяина", s.panelID, s2.panelID)
|
||||
}
|
||||
after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(moved.db, 5), nil))
|
||||
if string(before) != string(after) {
|
||||
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
|
||||
}
|
||||
if !moved.nodes.Exists(0) || moved.nodes.IsOnline(0) || moved.nodes.DefaultID() != 0 {
|
||||
t.Errorf("нода #0: exists=%v online=%v default=%d", moved.nodes.Exists(0), moved.nodes.IsOnline(0), moved.nodes.DefaultID())
|
||||
}
|
||||
if ids := moved.nodes.IDs(); len(ids) != 2 || ids[0] != 0 || ids[1] != 1 {
|
||||
t.Errorf("IDs = %v", ids)
|
||||
}
|
||||
if r, _ := moved.nodes.CtrlFor(0)("GET", "/health", nil); r.Status != 502 {
|
||||
t.Errorf("нода #0 не на связи: %d, ожидали 502", r.Status)
|
||||
}
|
||||
if l := moved.nodes.List(); len(l) != 2 || l[0].Local || !l[0].Pending {
|
||||
t.Errorf("List: %+v", l)
|
||||
}
|
||||
// Без SUB_HOST адрес подписки — не endpoint ноды #0: это другая машина.
|
||||
s2.host = ""
|
||||
if h := s2.subHost(nil); h != "" {
|
||||
t.Errorf("subHost = %q, ожидали пусто", h)
|
||||
}
|
||||
|
||||
// Свой VPN и нода #0 — один id: такую базу с LOCAL_NODE=on не открываем.
|
||||
t.Setenv("LOCAL_NODE", "on")
|
||||
if _, err := newNodes(&App{db: moved.db, baseDir: newDir}); err == nil {
|
||||
t.Error("LOCAL_NODE=on при ноде #0: ожидали ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProxyV1(t *testing.T) {
|
||||
for line, want := range map[string]string{
|
||||
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "192.0.2.1:51000",
|
||||
"PROXY TCP6 2001:db8::1 2001:db8::2 51000 40123": "[2001:db8::1]:51000",
|
||||
"PROXY UNKNOWN": "unknown",
|
||||
"PROXY UNKNOWN ffff::1 ffff::2 1 2": "unknown",
|
||||
"PROXY TCP4 2001:db8::1 198.51.100.2 1 2": "",
|
||||
"PROXY TCP4 192.0.2.1 198.51.100.2 70000 1": "",
|
||||
"PROXY TCP4 192.0.2.1 198.51.100.2 1": "",
|
||||
"PROXY TCP5 192.0.2.1 198.51.100.2 1 2": "",
|
||||
"GET / HTTP/1.1": "",
|
||||
"PROXY TCP4 192.0.2.1 not-an-ip 1 2": "",
|
||||
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "",
|
||||
} {
|
||||
addr, ok := parseProxyV1(line)
|
||||
got := ""
|
||||
switch {
|
||||
case ok && addr == nil:
|
||||
got = "unknown"
|
||||
case ok:
|
||||
got = addr.String()
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%q → %q, ожидали %q", line, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Заголовок PROXY — только от доверенного адреса; соединение с него без заголовка проходит как
|
||||
// есть, а от остальных заголовок остаётся байтами потока.
|
||||
func TestProxyListener(t *testing.T) {
|
||||
try := func(trusted bool, send string) (remote, data string) {
|
||||
t.Helper()
|
||||
raw, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer raw.Close()
|
||||
ln := newProxyListener(raw, map[string]bool{"127.0.0.1": trusted})
|
||||
cl, err := net.Dial("tcp", raw.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cl.Close()
|
||||
io.WriteString(cl, send)
|
||||
cl.(*net.TCPConn).CloseWrite()
|
||||
done := make(chan net.Conn, 1)
|
||||
go func() { c, _ := ln.Accept(); done <- c }()
|
||||
select {
|
||||
case c := <-done:
|
||||
defer c.Close()
|
||||
b, _ := io.ReadAll(bufio.NewReader(c))
|
||||
return c.RemoteAddr().String(), string(b)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Accept не вернулся")
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
if r, d := try(true, "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"); r != "192.0.2.1:51000" || d != "hello" {
|
||||
t.Errorf("доверенный с заголовком: %s %q", r, d)
|
||||
}
|
||||
if r, d := try(true, "GET / HTTP/1.1\r\n"); !strings.HasPrefix(r, "127.0.0.1:") || d != "GET / HTTP/1.1\r\n" {
|
||||
t.Errorf("доверенный без заголовка: %s %q", r, d)
|
||||
}
|
||||
hdr := "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"
|
||||
if r, d := try(false, hdr); !strings.HasPrefix(r, "127.0.0.1:") || d != hdr {
|
||||
t.Errorf("чужой с заголовком: %s %q", r, d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
"github.com/maeneko/forgetting/internal/migrate"
|
||||
)
|
||||
|
||||
type movePanel struct {
|
||||
a *App
|
||||
s *Sub
|
||||
env string
|
||||
calls [][]string
|
||||
}
|
||||
|
||||
func newMovePanel(t *testing.T, local, nodePort string) *movePanel {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LOCAL_NODE", local)
|
||||
t.Setenv("NODE_PORT", nodePort)
|
||||
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
|
||||
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
|
||||
db, err := openUIDB(filepath.Join(dir, "ui.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
a := &App{db: db, baseDir: dir, port: 34001}
|
||||
if a.nodes, err = newNodes(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.sub = &Sub{a: a, host: "203.0.113.7", port: 40123, locks: map[int]*sync.Mutex{}}
|
||||
if err := a.sub.initSchema(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &movePanel{a: a, s: a.sub, env: filepath.Join(dir, "cli.env")}
|
||||
a.migration = newMigration(a)
|
||||
a.migration.envFile = p.env
|
||||
a.migration.run = func(args ...string) { p.calls = append(p.calls, args) }
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *movePanel) call(t *testing.T, h handler, body jsjson.Object) (int, jsjson.Object) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
h(&Ctx{W: w, R: httptest.NewRequest("POST", "/", nil), Body: body})
|
||||
v, _ := jsjson.Decode(w.Body.Bytes())
|
||||
o, _ := v.(jsjson.Object)
|
||||
return w.Code, o
|
||||
}
|
||||
|
||||
// Вкладка «Перенос» целиком: код новой панели → архив из старой по HTTP → нода #0, join и
|
||||
// перезапуск. У клиента конфиг тот же, у старой панели подписка заморожена до join.
|
||||
func TestMigrateBetweenPanels(t *testing.T) {
|
||||
old := newMovePanel(t, "on", "")
|
||||
os.WriteFile(filepath.Join(old.a.baseDir, "sub_sign.key"), []byte("ключ подписи"), 0o600)
|
||||
for _, q := range []string{
|
||||
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
|
||||
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0)",
|
||||
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
|
||||
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0')",
|
||||
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0"}', 1)`,
|
||||
} {
|
||||
if _, err := old.a.db.Exec(q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
before := jsjson.MustMarshal(old.s.buildConfig(old.s.deviceByID(old.a.db, 5), nil))
|
||||
|
||||
fresh := newMovePanel(t, "off", "40500")
|
||||
m := fresh.a.migration
|
||||
if why := m.canAccept(); why != "" {
|
||||
t.Fatalf("чистая панель без VPN не принимает: %s", why)
|
||||
}
|
||||
if why := old.a.migration.canAccept(); why == "" {
|
||||
t.Error("панель со своим VPN принимает перенос")
|
||||
}
|
||||
if why := fresh.a.migration.canExport(); why == "" {
|
||||
t.Error("панель без VPN отдаёт перенос")
|
||||
}
|
||||
rt := newRouter()
|
||||
rt.Post("/migrate/v1/import", m.importHandler)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, raw, _, _ := readBody(r, rt.bodyLimit)
|
||||
rt.routes[0].hs[0](&Ctx{W: w, R: r, Body: body, RawBody: raw})
|
||||
}))
|
||||
defer srv.Close()
|
||||
u, _ := url.Parse(srv.URL)
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
m.key, m.host, m.expires = migrate.NewKey(), "127.0.0.1", nowFn().Add(moveCodeTTL)
|
||||
|
||||
// Чужой ключ: архив не принят, код живёт до пятой неудачи.
|
||||
wrong := migrate.Code{Host: "127.0.0.1", Port: port, Key: migrate.NewKey()}.String()
|
||||
if st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: wrong}}); st != 502 || !strings.Contains(o.Get2("error").(string), "Код не подходит") {
|
||||
t.Fatalf("чужой код: %d %v", st, o)
|
||||
}
|
||||
if old.s.frozen.Load() || m.key == nil {
|
||||
t.Fatal("после отказа подписка заморожена или код сгорел")
|
||||
}
|
||||
|
||||
code := migrate.Code{Host: "127.0.0.1", Port: port, Key: m.key}.String()
|
||||
st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: code}})
|
||||
if st != 200 || o.Get2("target") != "http://127.0.0.1:"+u.Port() {
|
||||
t.Fatalf("export: %d %v", st, o)
|
||||
}
|
||||
if !old.s.frozen.Load() {
|
||||
t.Error("старая подписка не заморожена до join")
|
||||
}
|
||||
if len(old.calls) != 1 || old.calls[0][0] != "join" {
|
||||
t.Fatalf("старая панель: %v", old.calls)
|
||||
}
|
||||
j, _ := jsjson.Decode(fromB64url(strings.TrimPrefix(old.calls[0][1], "awgjoin://")))
|
||||
if field(j, "n") != 0.0 || field(j, "p") != 40500.0 || field(j, "sub") != 40123.0 || field(j, "ui") != 34001.0 {
|
||||
t.Errorf("join: %s", jsjson.MustMarshal(j))
|
||||
}
|
||||
if raw, _ := os.ReadFile(old.env); !strings.Contains(string(raw), "PANEL_MOVED=on") {
|
||||
t.Errorf("старый cli.env: %q", raw)
|
||||
}
|
||||
if len(fresh.calls) != 1 || fresh.calls[0][0] != "restart" || m.state != moveImported || m.key != nil {
|
||||
t.Errorf("новая панель: %v, state %q", fresh.calls, m.state)
|
||||
}
|
||||
raw, _ := os.ReadFile(fresh.env)
|
||||
for _, want := range []string{"SUB_PORT=40123", "NODE_PORT=40500", "SUB_HOST=127.0.0.1", "RELAY_FROM=127.0.0.1"} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Errorf("новый cli.env без %s: %q", want, raw)
|
||||
}
|
||||
}
|
||||
if k, _ := os.ReadFile(filepath.Join(fresh.a.baseDir, "sub_sign.key")); string(k) != "ключ подписи" {
|
||||
t.Errorf("ключ подписи: %q", k)
|
||||
}
|
||||
|
||||
// Новая панель после перезапуска: та же база + нода #0 → тот же конфиг.
|
||||
t.Setenv("NODE_PORT", "")
|
||||
db, err := openUIDB(filepath.Join(fresh.a.baseDir, "ui.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
moved := &App{db: db, baseDir: fresh.a.baseDir}
|
||||
if moved.nodes, err = newNodes(moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
|
||||
if err := s2.initSchema(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(db, 5), nil)); string(after) != string(before) {
|
||||
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
|
||||
}
|
||||
if !moved.nodes.Exists(0) {
|
||||
t.Error("нет ноды #0")
|
||||
}
|
||||
|
||||
// Повторный приём: кода уже нет.
|
||||
if st, _ := old.call(t, func(c *Ctx) bool { old.a.migration.state = moveIdle; return old.a.migration.export(c) },
|
||||
jsjson.Object{{Key: "code", Val: code}}); st != 502 {
|
||||
t.Errorf("второй перенос тем же кодом: %d", st)
|
||||
}
|
||||
}
|
||||
+48
-14
@@ -6,7 +6,9 @@ package ui
|
||||
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
|
||||
//
|
||||
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает
|
||||
// как чистая панель. Протокол и формат join-строки — docs/node-protocol.md.
|
||||
// как чистая панель. Номер 0 тогда может занять нода — сервер, с которого панель переехала
|
||||
// (`awg-control migrate`): так у его пиров, ключей API и устройств не меняется id сервера.
|
||||
// Протокол и формат join-строки — docs/node-protocol.md.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -201,6 +203,18 @@ func newNodes(a *App) (*Nodes, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// core_ack — адрес панели, который агент записал к себе (кадр core_ok): нода переживёт
|
||||
// выключение сервера, через который подключена сейчас.
|
||||
if !hasColumn(a.db, "nodes", "core_ack") {
|
||||
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN core_ack TEXT"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Нода №0 и локальный сервер — один и тот же id: вместе их не бывает.
|
||||
if n.local && n.row(0) != nil {
|
||||
return nil, fmt.Errorf("nodes: в ui.db есть нода #0 (сервер, с которого переехала панель), а LOCAL_NODE=on — " +
|
||||
"у этой панели свой VPN не включают")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -294,15 +308,15 @@ func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp {
|
||||
|
||||
// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS.
|
||||
func (n *Nodes) CtrlFor(id int) Ctrl {
|
||||
if id == 0 {
|
||||
if !n.local {
|
||||
if id == 0 && n.local {
|
||||
return n.a.ctrlLocal
|
||||
}
|
||||
if n.row(int64(id)) == nil {
|
||||
if id == 0 {
|
||||
return func(string, string, any) (*CtrlResp, error) {
|
||||
return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil
|
||||
}
|
||||
}
|
||||
return n.a.ctrlLocal
|
||||
}
|
||||
if n.row(int64(id)) == nil {
|
||||
return func(string, string, any) (*CtrlResp, error) {
|
||||
return &CtrlResp{404, errObj("Сервер не найден")}, nil
|
||||
}
|
||||
@@ -311,16 +325,16 @@ func (n *Nodes) CtrlFor(id int) Ctrl {
|
||||
}
|
||||
|
||||
func (n *Nodes) Exists(id int) bool {
|
||||
if id == 0 {
|
||||
return n.local
|
||||
if id == 0 && n.local {
|
||||
return true
|
||||
}
|
||||
return n.row(int64(id)) != nil
|
||||
}
|
||||
|
||||
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
|
||||
func (n *Nodes) IsOnline(id int) bool {
|
||||
if id == 0 {
|
||||
return n.local
|
||||
if id == 0 && n.local {
|
||||
return true
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
@@ -385,7 +399,7 @@ func (n *Nodes) DefaultID() int {
|
||||
|
||||
func (n *Nodes) refreshHealth(id int64) {
|
||||
var r *CtrlResp
|
||||
if id == 0 {
|
||||
if id == 0 && n.local {
|
||||
r, _ = n.a.ctrlLocal("GET", "/health", nil)
|
||||
} else {
|
||||
r = n.rpc(id, "GET", "/health", nil)
|
||||
@@ -532,7 +546,11 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
||||
n.dropConn(row.ID, old)
|
||||
closeWS(old.ws, 4000, "replaced")
|
||||
}
|
||||
ws.Write(context.Background(), websocket.MessageText, []byte(`{"t":"ready"}`))
|
||||
ready := jsjson.Object{{Key: "t", Val: "ready"}}
|
||||
if home := n.home(); home != "" {
|
||||
ready = append(ready, jsjson.KV{Key: "core", Val: home})
|
||||
}
|
||||
ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(ready))
|
||||
first := ""
|
||||
if joining {
|
||||
first = " (первое подключение)"
|
||||
@@ -561,6 +579,12 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
||||
authenticate(m)
|
||||
continue
|
||||
}
|
||||
if m.Get2("t") == "core_ok" {
|
||||
if home, ok := m.Get2("core").(string); ok && home == n.home() {
|
||||
n.a.db.Exec("UPDATE nodes SET core_ack = ? WHERE id = ?", home, nodeID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
|
||||
rid := int64(m.Get2("id").(float64))
|
||||
conn.mu.Lock()
|
||||
@@ -598,7 +622,7 @@ func (n *Nodes) Start() {
|
||||
srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second,
|
||||
ErrorLog: quietLog()}
|
||||
go func() {
|
||||
ln, err := net.Listen("tcp", srv.Addr)
|
||||
ln, err := listen(n.port)
|
||||
if err != nil {
|
||||
warnf("nodes: хаб не запущен: %v", err)
|
||||
return
|
||||
@@ -656,6 +680,16 @@ func (n *Nodes) Start() {
|
||||
|
||||
func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil }
|
||||
|
||||
// home — публичный адрес хаба, который он сообщает нодам в ready (агент звонит туда первым).
|
||||
// Из SUB_HOST: у панели один публичный адрес; не задан — нодам хватает адреса из join.
|
||||
func (n *Nodes) home() string {
|
||||
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
|
||||
if host == "" || !n.hubReady() {
|
||||
return ""
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.Itoa(n.port))
|
||||
}
|
||||
|
||||
func (n *Nodes) joinString(host string, id int64, secret []byte) string {
|
||||
b := jsjson.MustMarshal(jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: n.port},
|
||||
{Key: "pin", Val: b64url.EncodeToString(n.pin)}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}})
|
||||
@@ -732,7 +766,7 @@ func (s serverInfo) json() jsjson.Object {
|
||||
|
||||
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
|
||||
f := jsToNumber(c.Params["id"])
|
||||
return int64(f), f == float64(int64(f)) && f >= 1
|
||||
return int64(f), f == float64(int64(f)) && (f >= 1 || f == 0 && !n.local)
|
||||
}
|
||||
|
||||
func (n *Nodes) router() *Router {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package ui
|
||||
|
||||
// PROXY protocol v1 от ретранслятора (`awg-control relay`): после переноса панели старый
|
||||
// сервер пересылает на неё подписку, хаб нод и панель. Без заголовка все клиенты пришли бы с
|
||||
// его IP и делили бы на всех лимиты по адресу (неудачные запросы, регистрации, вход). Заголовку
|
||||
// верим только от адресов из RELAY_FROM; от остальных он — просто байты соединения.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const proxyHeaderTimeout = 5 * time.Second
|
||||
|
||||
// relayFrom — RELAY_FROM: IP ретрансляторов через запятую.
|
||||
func relayFrom() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, s := range strings.Split(os.Getenv("RELAY_FROM"), ",") {
|
||||
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
|
||||
out[ip.String()] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listen — net.Listen("tcp", :port), а при заданном RELAY_FROM — с разбором PROXY v1.
|
||||
func listen(port int) (net.Listener, error) {
|
||||
ln, err := net.Listen("tcp", ":"+strconv.Itoa(port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if trusted := relayFrom(); len(trusted) > 0 {
|
||||
return newProxyListener(ln, trusted), nil
|
||||
}
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
type accepted struct {
|
||||
c net.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
// proxyListener читает заголовок в своей горутине на соединение: медленный ретранслятор не
|
||||
// держит Accept для остальных.
|
||||
type proxyListener struct {
|
||||
net.Listener
|
||||
trusted map[string]bool
|
||||
ch chan accepted
|
||||
}
|
||||
|
||||
func newProxyListener(ln net.Listener, trusted map[string]bool) *proxyListener {
|
||||
l := &proxyListener{Listener: ln, trusted: trusted, ch: make(chan accepted)}
|
||||
go l.loop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *proxyListener) loop() {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
l.ch <- accepted{nil, err}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
|
||||
if ip := net.ParseIP(host); ip == nil || !l.trusted[ip.String()] {
|
||||
l.ch <- accepted{c, nil}
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
if pc := readProxyHeader(c); pc != nil {
|
||||
l.ch <- accepted{pc, nil}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *proxyListener) Accept() (net.Conn, error) {
|
||||
a := <-l.ch
|
||||
return a.c, a.err
|
||||
}
|
||||
|
||||
type proxyConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
remote net.Addr
|
||||
}
|
||||
|
||||
func (c *proxyConn) Read(b []byte) (int, error) { return c.r.Read(b) }
|
||||
func (c *proxyConn) RemoteAddr() net.Addr { return c.remote }
|
||||
|
||||
// readProxyHeader — соединение без заголовка остаётся как есть (с ретранслятора может прийти и
|
||||
// обычный клиент); битый заголовок — закрываем. nil — соединение закрыто.
|
||||
func readProxyHeader(c net.Conn) net.Conn {
|
||||
c.SetReadDeadline(time.Now().Add(proxyHeaderTimeout))
|
||||
defer c.SetReadDeadline(time.Time{})
|
||||
r := bufio.NewReaderSize(c, 256)
|
||||
pc := &proxyConn{Conn: c, r: r, remote: c.RemoteAddr()}
|
||||
if sig, err := r.Peek(6); err != nil || string(sig) != "PROXY " {
|
||||
if err != nil && len(sig) == 0 {
|
||||
c.Close()
|
||||
return nil
|
||||
}
|
||||
return pc
|
||||
}
|
||||
// Строка v1 — не длиннее 107 байт вместе с CRLF.
|
||||
line, err := r.ReadSlice('\n')
|
||||
if err != nil || len(line) > 107 || !strings.HasSuffix(string(line), "\r\n") {
|
||||
c.Close()
|
||||
return nil
|
||||
}
|
||||
if addr, ok := parseProxyV1(strings.TrimSuffix(string(line), "\r\n")); ok {
|
||||
if addr != nil {
|
||||
pc.remote = addr
|
||||
}
|
||||
return pc
|
||||
}
|
||||
c.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseProxyV1 — «PROXY TCP4|TCP6 src dst sport dport» или «PROXY UNKNOWN …» (addr == nil).
|
||||
func parseProxyV1(line string) (*net.TCPAddr, bool) {
|
||||
f := strings.Split(line, " ")
|
||||
if len(f) >= 2 && f[0] == "PROXY" && f[1] == "UNKNOWN" {
|
||||
return nil, true
|
||||
}
|
||||
if len(f) != 6 || f[0] != "PROXY" || f[1] != "TCP4" && f[1] != "TCP6" {
|
||||
return nil, false
|
||||
}
|
||||
ip := net.ParseIP(f[2])
|
||||
if ip == nil || net.ParseIP(f[3]) == nil || (ip.To4() != nil) != (f[1] == "TCP4") {
|
||||
return nil, false
|
||||
}
|
||||
port, err := strconv.Atoi(f[4])
|
||||
if err != nil || port < 0 || port > 65535 {
|
||||
return nil, false
|
||||
}
|
||||
if p, err := strconv.Atoi(f[5]); err != nil || p < 0 || p > 65535 {
|
||||
return nil, false
|
||||
}
|
||||
return &net.TCPAddr{IP: ip, Port: port}, true
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
// После переноса старый сервер выключают, когда новый адрес знают все: хаб сообщает его
|
||||
// нодам в ready и запоминает подтверждение (core_ok), клиенты получают его в endpoints.
|
||||
// Вкладка «Перенос» показывает, сколько осталось.
|
||||
func TestRetireReadiness(t *testing.T) {
|
||||
t.Setenv("SUB_HOST", "198.51.100.20")
|
||||
p := newMovePanel(t, "off", "40500")
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
for _, q := range []string{
|
||||
"INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'Старый', 'x', 1)",
|
||||
"INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', '" + b64url.EncodeToString(pub) + "', 1)",
|
||||
"INSERT INTO nodes (id, name, created_at) VALUES (4, 'Ещё не подключена', 1)",
|
||||
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
|
||||
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (2, 'u2', 'Не открыта', x'02', 1)",
|
||||
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (3, 'u3', 'Отозвана', x'03', 1, 1)",
|
||||
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (5, 1, 'a', 'W5', 'A5', 1, 2000)",
|
||||
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (6, 1, 'b', 'W6', 'A6', 1, 500)",
|
||||
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (7, 1, 'c', 'W7', 'A7', 1)",
|
||||
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (8, 3, 'd', 'W8', 'A8', 1, 2000)",
|
||||
} {
|
||||
if _, err := p.a.db.Exec(q); err != nil {
|
||||
t.Fatal(q, err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
|
||||
defer srv.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ws.CloseNow()
|
||||
read := func() jsjson.Object {
|
||||
_, raw, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, _ := jsjson.Decode(raw)
|
||||
o, _ := v.(jsjson.Object)
|
||||
return o
|
||||
}
|
||||
write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) }
|
||||
nonce := read().Get2("nonce").(string)
|
||||
write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
|
||||
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}})
|
||||
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
|
||||
t.Fatalf("ready: %s", got)
|
||||
}
|
||||
|
||||
retire := func() jsjson.Object {
|
||||
_, o := p.call(t, p.a.migration.status, nil)
|
||||
r, _ := o.Get2("retire").(jsjson.Object)
|
||||
return r
|
||||
}
|
||||
if retire() != nil {
|
||||
t.Error("готовность показана панели, которая ничего не принимала")
|
||||
}
|
||||
t.Setenv("MOVED_IN_AT", "1000")
|
||||
if n := string(jsjson.MustMarshal(retire().Get2("nodes"))); n != `[{"id":3,"name":"Frankfurt","online":true,"ok":false}]` {
|
||||
t.Errorf("до core_ok: %s", n)
|
||||
}
|
||||
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "203.0.113.66:1"}}) // не наш адрес — мимо
|
||||
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "198.51.100.20:40500"}})
|
||||
var r jsjson.Object
|
||||
for range 50 {
|
||||
if r = retire(); strings.Contains(string(jsjson.MustMarshal(r.Get2("nodes"))), `"ok":true`) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
want := `{"since":1000,"home":"198.51.100.20:40500","old":{"exists":true,"online":false},` +
|
||||
`"devices":{"total":3,"fresh":1},"unused_links":1,"nodes":[{"id":3,"name":"Frankfurt","online":true,"ok":true}]}`
|
||||
if got := string(jsjson.MustMarshal(r)); got != want {
|
||||
t.Errorf("готовность:\n got %s\nwant %s", got, want)
|
||||
}
|
||||
|
||||
os.WriteFile(p.env, []byte("UI_PASS=x\nMOVED_IN_AT=1000\n"), 0o600)
|
||||
w := httptest.NewRecorder()
|
||||
p.a.migration.router().ServeHTTP(w, httptest.NewRequest("DELETE", "/retire", nil))
|
||||
if raw, _ := os.ReadFile(p.env); strings.Contains(string(raw), "MOVED_IN_AT") || retire() != nil {
|
||||
t.Errorf("отметка не убрана: %q", raw)
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -11,7 +11,6 @@ import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -109,10 +108,11 @@ type App struct {
|
||||
revoked map[string]bool
|
||||
loginAttempts map[string]*rateEntry
|
||||
|
||||
nodes *Nodes
|
||||
sub *Sub
|
||||
metrics *Metrics
|
||||
monitor *Monitor
|
||||
nodes *Nodes
|
||||
sub *Sub
|
||||
metrics *Metrics
|
||||
monitor *Monitor
|
||||
migration *Migration
|
||||
}
|
||||
|
||||
// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса
|
||||
@@ -185,6 +185,7 @@ func New() (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
a.monitor = newMonitor(a)
|
||||
a.migration = newMigration(a)
|
||||
a.nodes.BeforeDelete(a.apiKeysBeforeDelete)
|
||||
a.nodes.AfterDelete(a.apiKeysAfterDelete)
|
||||
return a, nil
|
||||
@@ -876,6 +877,10 @@ func (a *App) routes() *Router {
|
||||
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
|
||||
app.Use("/ui/monitor", auth)
|
||||
app.Mount("/ui/monitor", a.monitor.router())
|
||||
// Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса.
|
||||
app.Post("/migrate/v1/import", a.migration.importHandler)
|
||||
app.Use("/ui/migrate", auth)
|
||||
app.Mount("/ui/migrate", a.migration.router())
|
||||
|
||||
for _, p := range []string{"/api", "/health", "/awg"} {
|
||||
app.Use(p, auth, a.proxy)
|
||||
@@ -937,7 +942,7 @@ func serveFile(w http.ResponseWriter, r *http.Request, f string) {
|
||||
|
||||
// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку).
|
||||
func (a *App) Run() error {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port))
|
||||
ln, err := listen(a.port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+14
-3
@@ -29,13 +29,13 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -194,6 +194,8 @@ type Sub struct {
|
||||
|
||||
registerLimit, failLimit *limiter
|
||||
|
||||
frozen atomic.Bool // идёт перенос панели (migrate.go): /sub/v1 отвечает «недоступна»
|
||||
|
||||
auditMu sync.Mutex
|
||||
lastAudit *auditReport
|
||||
auditSettings auditSettings
|
||||
@@ -780,7 +782,8 @@ func (s *Sub) subHost(c *Ctx) string {
|
||||
if s.host != "" {
|
||||
return s.host
|
||||
}
|
||||
if s.a.nodes.Exists(0) {
|
||||
// Только свой сервер: у ноды №0 (переехавшей панели) endpoint — другая машина.
|
||||
if s.a.nodes.local {
|
||||
if p := s.storedProfile(0); p != nil {
|
||||
if ep, ok := p.Get2("endpoint").(string); ok {
|
||||
return hostOfEndpoint(ep)
|
||||
@@ -1200,6 +1203,14 @@ func (s *Sub) publicRouter() *Router {
|
||||
s.send(w, r, 400, subErr("bad_request"))
|
||||
}
|
||||
rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) }
|
||||
// Перенос панели: изменения сейчас остались бы только в уезжающей базе. 502 unavailable
|
||||
// клиент понимает как «подписка недоступна» — туннель не трогает и повторит позже.
|
||||
rt.Use("/", func(c *Ctx) bool {
|
||||
if s.frozen.Load() {
|
||||
return s.sendC(c, 502, subErr("unavailable"))
|
||||
}
|
||||
return true
|
||||
})
|
||||
rt.Post("/sub/v1/register", s.wrapSub(s.register))
|
||||
rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
|
||||
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
|
||||
@@ -1248,7 +1259,7 @@ func (s *Sub) Start() {
|
||||
return
|
||||
}
|
||||
srv := &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: s.publicRouter(), ReadHeaderTimeout: 15 * time.Second, ErrorLog: quietLog()}
|
||||
ln, err := net.Listen("tcp", srv.Addr)
|
||||
ln, err := listen(s.port)
|
||||
if err != nil {
|
||||
warnf("sub: листенер не запущен: %v", err)
|
||||
return
|
||||
|
||||
+227
-32
@@ -210,6 +210,12 @@ func (s *Sub) createMaster(body any, scope []int) (jsjson.Object, error) {
|
||||
list = append(list, float64(id))
|
||||
}
|
||||
src = list
|
||||
} else if def := s.defaultServers(); def != nil {
|
||||
list := []any{}
|
||||
for _, id := range def {
|
||||
list = append(list, float64(id))
|
||||
}
|
||||
src = list
|
||||
} else {
|
||||
src = []any{float64(s.a.nodes.DefaultID())}
|
||||
}
|
||||
@@ -281,37 +287,7 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
|
||||
removed = append(removed, x)
|
||||
}
|
||||
}
|
||||
devices := s.devicesOf(s.a.db, m.ID)
|
||||
if err := s.a.tx(func(tx *sql.Tx) error {
|
||||
for _, sid := range removed {
|
||||
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range devices {
|
||||
for _, l := range s.linksOf(tx, d.ID) {
|
||||
if l.ServerID == sid && l.PeerPub.Valid {
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, sid := range added {
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range devices {
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
if err := s.a.tx(func(tx *sql.Tx) error { return s.moveServersTx(tx, m.ID, added, removed) }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
affected = append(append(affected, added...), removed...)
|
||||
@@ -339,6 +315,215 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
|
||||
{Key: "servers", Val: intsJSON(ids)}, {Key: "unbound", Val: unbound}}, nil
|
||||
}
|
||||
|
||||
// moveServersTx — ключ получает серверы added и теряет removed: пиры его устройств на снятых
|
||||
// серверах — в надгробия (их снимет reconcile), на добавленных — заявки без пира (заведёт он же).
|
||||
func (s *Sub) moveServersTx(tx *sql.Tx, masterID int64, added, removed []int) error {
|
||||
devices := s.devicesOf(tx, masterID)
|
||||
for _, sid := range removed {
|
||||
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", masterID, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range devices {
|
||||
for _, l := range s.linksOf(tx, d.ID) {
|
||||
if l.ServerID == sid && l.PeerPub.Valid {
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, sid := range added {
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", masterID, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range devices {
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Общая настройка серверов ────────────────────────────────────────────────
|
||||
|
||||
// defaultServers — серверы новых ключей панели (без явного набора), из настройки «Серверы для
|
||||
// всех»; nil — не задано или ни одного из них уже нет.
|
||||
func (s *Sub) defaultServers() []int {
|
||||
var out []int
|
||||
for _, v := range arr(s.metaGet("default_servers")) {
|
||||
if isInt(v) && s.a.nodes.Exists(int(v.(float64))) {
|
||||
out = append(out, int(v.(float64)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bulkServers — один набор правок для всех живых ключей: add — у всех, remove — ни у кого,
|
||||
// остальные серверы каждого ключа не трогаются. dry — только посчитать. hidden — ключи API,
|
||||
// которые потеряют доступ к части ключей: ключ виден API-ключу, только пока все его серверы в
|
||||
// области (visible), и добавление сервера вне области его прячет.
|
||||
func (s *Sub) bulkServers(body any) (jsjson.Object, error) {
|
||||
b, _ := body.(jsjson.Object)
|
||||
ids := func(k string) ([]int, error) {
|
||||
v, has := b.Get(k)
|
||||
if !has || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if l, ok := v.([]any); ok && len(l) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out, ok := s.a.serverList(v, s.a.nodes.Exists)
|
||||
if !ok {
|
||||
return nil, fail(400, "Такого сервера нет")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
add, err := ids("add")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remove, err := ids("remove")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range add {
|
||||
if containsInt(remove, x) {
|
||||
return nil, fail(400, "Сервер не может быть и у всех, и ни у кого")
|
||||
}
|
||||
}
|
||||
defaults, hasDefaults := b.Get("defaults")
|
||||
var defIDs []int
|
||||
if hasDefaults && defaults != nil {
|
||||
if defIDs, err = s.parseServers(defaults, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
type change struct {
|
||||
id int64
|
||||
added, removed []int
|
||||
before, after []int
|
||||
}
|
||||
var changes []change
|
||||
var masters []int64
|
||||
rows, err := s.a.db.Query("SELECT id FROM master_keys WHERE revoked_at IS NULL ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if rows.Scan(&id) == nil {
|
||||
masters = append(masters, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
empty := 0
|
||||
for _, id := range masters {
|
||||
cur := s.serverIDsOf(s.a.db, id)
|
||||
c := change{id: id, before: cur}
|
||||
for _, x := range cur {
|
||||
if containsInt(remove, x) {
|
||||
c.removed = append(c.removed, x)
|
||||
} else {
|
||||
c.after = append(c.after, x)
|
||||
}
|
||||
}
|
||||
for _, x := range add {
|
||||
if !containsInt(cur, x) {
|
||||
c.added = append(c.added, x)
|
||||
c.after = append(c.after, x)
|
||||
}
|
||||
}
|
||||
if len(c.after) == 0 {
|
||||
empty++
|
||||
}
|
||||
if len(c.added)+len(c.removed) > 0 {
|
||||
changes = append(changes, c)
|
||||
}
|
||||
}
|
||||
if empty > 0 {
|
||||
return nil, fail(409, fmt.Sprintf("У %d ключей не останется ни одного сервера — оставьте им хотя бы один", empty))
|
||||
}
|
||||
|
||||
// Области ключей API: id → серверы.
|
||||
type apiKey struct {
|
||||
label string
|
||||
scope []int
|
||||
}
|
||||
keys := map[int64]*apiKey{}
|
||||
var order []int64
|
||||
if r, err := s.a.db.Query("SELECT k.id, k.label, ks.server_id FROM api_keys k LEFT JOIN api_key_servers ks ON ks.key_id = k.id ORDER BY k.id"); err == nil {
|
||||
for r.Next() {
|
||||
var id int64
|
||||
var label string
|
||||
var sid sql.NullInt64
|
||||
if r.Scan(&id, &label, &sid) != nil {
|
||||
continue
|
||||
}
|
||||
if keys[id] == nil {
|
||||
keys[id] = &apiKey{label: label}
|
||||
order = append(order, id)
|
||||
}
|
||||
if sid.Valid {
|
||||
keys[id].scope = append(keys[id].scope, int(sid.Int64))
|
||||
}
|
||||
}
|
||||
r.Close()
|
||||
}
|
||||
within := func(list, scope []int) bool {
|
||||
for _, x := range list {
|
||||
if !containsInt(scope, x) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
hidden := []any{}
|
||||
for _, id := range order {
|
||||
k, n := keys[id], 0
|
||||
for _, c := range changes {
|
||||
if within(c.before, k.scope) && !within(c.after, k.scope) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
hidden = append(hidden, jsjson.Object{{Key: "id", Val: id}, {Key: "label", Val: k.label}, {Key: "keys", Val: n}})
|
||||
}
|
||||
}
|
||||
out := jsjson.Object{{Key: "changed", Val: len(changes)}, {Key: "total", Val: len(masters)}, {Key: "hidden", Val: hidden}}
|
||||
if b.Get2("dry_run") == true {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var affected []int
|
||||
if err := s.a.tx(func(tx *sql.Tx) error {
|
||||
for _, c := range changes {
|
||||
if err := s.moveServersTx(tx, c.id, c.added, c.removed); err != nil {
|
||||
return err
|
||||
}
|
||||
affected = append(append(affected, c.added...), c.removed...)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasDefaults {
|
||||
if defIDs == nil {
|
||||
s.a.db.Exec("DELETE FROM sub_meta WHERE key = 'default_servers'")
|
||||
} else {
|
||||
s.metaSet("default_servers", intsJSON(defIDs))
|
||||
}
|
||||
}
|
||||
logf("masterkeys: серверы для всех — добавлены %v, сняты %v, ключей изменено: %d", add, remove, len(changes))
|
||||
s.reconcileAll(affected)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rotateMaster — новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
|
||||
func (s *Sub) rotateMaster(m *masterRow) {
|
||||
secret := make([]byte, 16)
|
||||
@@ -571,8 +756,18 @@ func (s *Sub) masterKeysRouter() *Router {
|
||||
master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) }
|
||||
|
||||
rt.Get("/", func(c *Ctx) bool {
|
||||
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)}})
|
||||
var defaults any
|
||||
if d := s.defaultServers(); d != nil {
|
||||
defaults = intsJSON(d)
|
||||
}
|
||||
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)},
|
||||
{Key: "defaults", Val: defaults}})
|
||||
})
|
||||
// «Серверы для всех»: добавить/снять серверы у всех ключей разом и задать набор для новых.
|
||||
rt.Post("/servers", aw(func(c *Ctx) (int, any, error) {
|
||||
out, err := s.bulkServers(c.Body)
|
||||
return 200, out, err
|
||||
}))
|
||||
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
|
||||
rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) })
|
||||
rt.Post("/audit", aw(func(c *Ctx) (int, any, error) {
|
||||
|
||||
Reference in New Issue
Block a user