diff --git a/awg-agent/index.ts b/awg-agent/index.ts index f907e99..0c26b23 100644 --- a/awg-agent/index.ts +++ b/awg-agent/index.ts @@ -23,6 +23,16 @@ const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnez const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? ""; let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения +// Версия Forgetting на этой ноде — панель показывает её в карточке сервера. CLI передаёт её из +// .env в корне проекта; при запуске вручную читаем тот же .env сами. +function envFromFile(file: string, key: string): string { + try { + const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m")); + return m ? m[1].trim() : ""; + } catch { return ""; } +} +const VERSION = process.env.VERSION || envFromFile(path.join(__dirname, "..", ".env"), "VERSION"); + if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) { console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`"); process.exit(1); @@ -138,6 +148,7 @@ function connectOnce(): Promise<{ authFailed: boolean }> { sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"), }; if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw }; + if (VERSION) auth.version = VERSION; ws.send(JSON.stringify(auth)); } else if (m.t === "ready") { ready = true; backoff = 1000; armDead(); diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 547cd66..1096998 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -15,6 +15,7 @@ const now = () => Math.floor(Date.now() / 1000); const rnd = (n: number) => Math.floor(Math.random() * n); const b64 = (n: number) => crypto.randomBytes(n).toString("base64"); const GEN = "3.1"; +const MOCK_VERSION = "0.3.3"; // ── пользователи (vpn://) ── interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number } @@ -34,12 +35,13 @@ const usersOf: Record = { }; const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; }; const U_ = (q: express.Request) => usersOf[srv(q)]; -interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean } +interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null } const mnodes: MNode[] = [ - { id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false }, - { id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true }, + { id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.3.2" }, // отстаёт от панели + { id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null }, + { id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: true, pending: false, version: null }, // старый агент: версию не шлёт ]; -let nodeId = 2; +let nodeId = 3; const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url"); // ── API-ключи ── @@ -58,7 +60,7 @@ const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_lim const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length }); const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting); -app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" })); +app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION })); app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" })); app.post("/logout", (_q, r) => r.json({ ok: true })); app.get("/health", (q, r) => { @@ -68,15 +70,15 @@ app.get("/health", (q, r) => { // ── серверы ── const nodeView = () => [ - { id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } }, - ...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, + { id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, version: MOCK_VERSION, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } }, + ...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, version: n.version, health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })), ]; -app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() })); +app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() })); app.post("/ui/nodes", (q, r) => { const name = String(q.body.name ?? ""); if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" }); - const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true }); + const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true, version: null }); r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 }); }); app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); }); diff --git a/awg-ui/nodes.ts b/awg-ui/nodes.ts index 0863523..47ba3bc 100644 --- a/awg-ui/nodes.ts +++ b/awg-ui/nodes.ts @@ -25,7 +25,7 @@ export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise< interface NodeRow { id: number; name: string; pub: string | null; join_hash: Buffer | null; join_expires: number | null; - created_at: number; last_seen: number | null; + created_at: number; last_seen: number | null; version: string | null; } interface Health { @@ -46,11 +46,12 @@ const RPC_TIMEOUT = 15_000; const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго const NAME_RE = /^[^\r\n<>]{1,40}$/; const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/; +const VERSION_RE = /^[\w.+-]{1,32}$/; // Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent. const authMsg = (nonce: string, nodeId: number) => Buffer.from(`awg-node-v1\n${nonce}\n${nodeId}`); -export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string }) { +export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string; version: string }) { const { uidb, ctrlLocal } = deps; const NODE_PORT = Number(process.env.NODE_PORT) || 0; @@ -94,6 +95,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba last_seen INTEGER ); `); + // Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают). Колонка + // добавлена позже, у существующей таблицы её может не быть. + if (!(uidb.prepare("PRAGMA table_info(nodes)").all() as { name: string }[]).some(c => c.name === "version")) + uidb.exec("ALTER TABLE nodes ADD COLUMN version TEXT"); const q = { all: uidb.prepare("SELECT * FROM nodes ORDER BY id"), byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"), @@ -101,7 +106,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"), rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"), bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"), - seen: uidb.prepare<[number, number]>("UPDATE nodes SET last_seen = ? WHERE id = ?"), + seen: uidb.prepare<[number, string | null, number]>("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?"), delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"), }; const now = () => Math.floor(Date.now() / 1000); @@ -225,7 +230,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba clearTimeout(authTimer); if (joining) q.bind.run(pubB64 as string, row.id); - q.seen.run(now(), row.id); + // Версия — только для показа в панели: не подписана, но и доверия не даёт. + const version = typeof m.version === "string" && VERSION_RE.test(m.version) ? m.version : null; + q.seen.run(now(), version, row.id); const old = conns.get(row.id); conn = { ws, seq: 0, alive: true, pending: new Map() }; @@ -303,15 +310,15 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba const nodes: unknown[] = []; if (LOCAL) { const h = health.get(0) ?? null; - nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, health: h }); + nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h }); } for (const n of q.all.all() as NodeRow[]) { nodes.push({ id: n.id, name: n.name, local: false, online: conns.has(n.id), - pending: n.pub === null, last_seen: n.last_seen, health: health.get(n.id) ?? null, + pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null, }); } - res.json({ hub: hubReady(), local: LOCAL, nodes }); + res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes }); }); router.post("/", (req: Request, res: Response) => { diff --git a/awg-ui/server.ts b/awg-ui/server.ts index 5b183ad..b22c7f4 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -355,7 +355,7 @@ app.use("/api/v1", ext); // sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный // листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется. // Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db. -const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname }); +const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname, version: VERSION }); app.use("/ui/nodes", requireAuth, nodes.router); // sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды). diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index bc7221f..bcd10d2 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -25,6 +25,8 @@ html, body, #root { height: 100%; } --neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85; --error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a; --warn-dot: #e08a2c; + --tertiary-container: #ffdcad; /* design.md: tertiary-container */ + --on-tertiary-container: #7a4a14; --danger-border: #e6c4bc; --danger-text: #b34a3a; @@ -67,6 +69,8 @@ html, body, #root { height: 100%; } --neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085; --error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449; --warn-dot: #ffb76b; + --tertiary-container: #4a3218; /* тёплый тёмный, как у статусов в тёмной теме */ + --on-tertiary-container: #ffdcad; --danger-border: #6e4540; --danger-text: #ffb4ab; @@ -449,6 +453,16 @@ body { line-height: 1.2; } .server-gen-wrap { align-self: flex-start; margin-top: 2px; } +.server-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; } +.server-chips .server-gen-wrap, .server-chips .server-gen-chip { margin-top: 0; } +.server-ver-chip { + font-family: var(--mono); + font-size: 11px; + padding: 2px 8px; + background: var(--neutral-bg); + color: var(--neutral-text); +} +.server-ver-chip--warn { background: var(--tertiary-container); color: var(--on-tertiary-container); } .server-gen-chip { align-self: flex-start; margin-top: 2px; diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index 6d813e5..5567d1e 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -23,6 +23,7 @@ export default function App() { const [statusText, setStatusText] = useState(''); const [nodes, setNodes] = useState([]); const [hubEnabled, setHubEnabled] = useState(false); + const [panelVersion, setPanelVersion] = useState(null); const [serverId, setServerIdState] = useState(null); const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null); const [msg, setMsg] = useState(''); @@ -55,6 +56,7 @@ export default function App() { const list: NodeInfo[] = data.nodes ?? []; setNodes(list); setHubEnabled(!!data.hub); + setPanelVersion(data.version ?? null); const cur = getServerId(); if (cur !== null && list.some(n => n.id === cur)) return; let saved: number | null = null; @@ -311,6 +313,7 @@ export default function App() { nodes={nodes} serverId={serverId} hubEnabled={hubEnabled} + panelVersion={panelVersion} onSelect={pickServer} onAdd={() => setModal({})} onJoin={n => setModal({ node: n })} diff --git a/awg-ui/src/components/ServerBar.tsx b/awg-ui/src/components/ServerBar.tsx index e5d8ca5..bff2761 100644 --- a/awg-ui/src/components/ServerBar.tsx +++ b/awg-ui/src/components/ServerBar.tsx @@ -1,17 +1,39 @@ // 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 { type NodeInfo, genLabel } from '../lib/shared'; +import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared'; import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons'; // Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и // (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает // серверы, между которыми переключается содержимое вкладки (пиры / абоненты). +// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию +// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить». +function versionChip(n: NodeInfo, panel: string | null) { + if (n.pending) return null; + let tip = '', warn = false; + if (!n.version) { + warn = true; + tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)'; + } else if (panel && !n.local) { + const d = cmpVersion(n.version, panel); + if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; } + else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; } + } + const chip = ( + + {n.version ? `v${n.version}` : 'v?.?.?'} + + ); + return tip ? {chip} : chip; +} + export default function ServerBar({ - nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete, + nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete, onRestartAwg, restarting, showRestart = true, }: { nodes: NodeInfo[]; + panelVersion: string | null; serverId: number | null; hubEnabled: boolean; onSelect: (id: number) => void; @@ -52,9 +74,12 @@ export default function ServerBar({ ) : !n.online ? ( нет связи ) : h && ( - <> - {h.ip} - {(h.module || h.tools) ? ( + {h.ip} + )} + {/* Плашки: поколение AWG (только на связи) и версия Forgetting (известна и без связи) */} + {!n.pending && ( + + {h && n.online && ((h.module || h.tools) ? ( ) : ( {genLabel(h.gen)} - )} - + ))} + {versionChip(n, panelVersion)} + )} {h && n.online && {h.peers} peers} diff --git a/awg-ui/src/lib/shared.ts b/awg-ui/src/lib/shared.ts index dee5f33..4cff905 100644 --- a/awg-ui/src/lib/shared.ts +++ b/awg-ui/src/lib/shared.ts @@ -60,9 +60,20 @@ export interface NodeInfo { online: boolean; pending: boolean; // нода создана, но ещё ни разу не подключалась last_seen: number | null; + version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4 health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null; } +// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b. +export function cmpVersion(a: string, b: string): number { + const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d) return d; + } + return 0; +} + // Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id // подставляется здесь; App ставит значение до смены key у вкладки, поэтому та // перечитывает данные уже с новым сервером.