From 5b29993d5d2fedfc350046cc20b2464e4923a75b Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Thu, 29 Oct 2026 22:59:09 +0300 Subject: [PATCH] Panel redesign, per-server I1 masking, node minimum version 0.5.0, installer output - UI: settings tab with sections (server, API keys, Prometheus, audit, migrate, about); server settings moved there; master keys table rework (menu, chips, version, compact phone cards); three-state node version chip - I1: random per-server template (DNS / QUIC Initial / STUN) kept in users.db, GET/POST /api/masking, change from the master keys banner - Hub rejects nodes older than 0.5.0 (close 4026), /ui/nodes reports outdated - install.sh: spinner for long commands (full output in the log), step progress bar, cards; installs older than 0.5.0 require a full reinstall with data reset - Bump to Beta 0.5.0 --- .env | 2 +- README.md | 4 +- awg-agent/package.json | 2 +- awg-ctrl/package.json | 2 +- awg-ui/mock-server.ts | 132 ++- awg-ui/nodes.ts | 3 +- awg-ui/package.json | 2 +- awg-ui/src/App.css | 776 ++++++++++++------ awg-ui/src/App.tsx | 253 ++++-- awg-ui/src/components/AddServerModal.tsx | 2 +- awg-ui/src/components/DrumTitle.tsx | 19 + awg-ui/src/components/ServerBar.tsx | 37 +- awg-ui/src/components/ServerSelect.tsx | 90 ++ awg-ui/src/components/icons.tsx | 25 + awg-ui/src/lib/qr.ts | 66 ++ awg-ui/src/lib/shared.ts | 26 + awg-ui/src/lib/theme.ts | 47 ++ awg-ui/src/tabs/apikeys/metadata.json | 7 - awg-ui/src/tabs/audit/metadata.json | 11 - awg-ui/src/tabs/index.ts | 7 +- .../src/tabs/masterkeys/BulkServersModal.tsx | 4 +- awg-ui/src/tabs/masterkeys/index.tsx | 390 ++++++--- awg-ui/src/tabs/masterkeys/masterkeys.css | 278 +++---- awg-ui/src/tabs/migrate/metadata.json | 11 - awg-ui/src/tabs/migrate/migrate.css | 26 - awg-ui/src/tabs/monitor/index.tsx | 260 +++--- awg-ui/src/tabs/monitor/metadata.json | 1 + awg-ui/src/tabs/monitor/monitor.css | 102 +-- .../tabs/{ => settings}/apikeys/apikeys.css | 35 +- .../src/tabs/{ => settings}/apikeys/index.tsx | 15 +- .../src/tabs/{ => settings}/audit/audit.css | 22 +- .../src/tabs/{ => settings}/audit/index.tsx | 10 +- awg-ui/src/tabs/settings/index.tsx | 285 +++++++ awg-ui/src/tabs/settings/metadata.json | 10 + .../src/tabs/{ => settings}/migrate/index.tsx | 10 +- awg-ui/src/tabs/settings/migrate/migrate.css | 25 + awg-ui/src/tabs/settings/prometheus/index.tsx | 141 ++++ .../tabs/settings/prometheus/prometheus.css | 31 + .../settings/server/index.tsx} | 75 +- awg-ui/src/tabs/settings/server/server.css | 11 + awg-ui/src/tabs/settings/settings.css | 119 +++ awg-ui/src/tabs/users/index.tsx | 11 +- awg-ui/src/tabs/users/users.css | 34 +- awg-ui/src/tokens.css | 164 ++++ awg-ui/vite.config.ts | 12 +- cli/package.json | 2 +- install.sh | 241 ++++-- internal/agent/agent.go | 3 + internal/ctrl/awg.go | 1 + internal/ctrl/i1.go | 219 +++++ internal/ctrl/i1_test.go | 58 ++ internal/ctrl/routes.go | 6 + internal/ctrl/server.go | 1 + internal/ui/monitor_test.go | 2 +- internal/ui/nodes.go | 42 +- internal/ui/outdated_test.go | 88 ++ internal/ui/retire_test.go | 2 +- 57 files changed, 3154 insertions(+), 1106 deletions(-) create mode 100644 awg-ui/src/components/DrumTitle.tsx create mode 100644 awg-ui/src/components/ServerSelect.tsx create mode 100644 awg-ui/src/lib/qr.ts create mode 100644 awg-ui/src/lib/theme.ts delete mode 100644 awg-ui/src/tabs/apikeys/metadata.json delete mode 100644 awg-ui/src/tabs/audit/metadata.json delete mode 100644 awg-ui/src/tabs/migrate/metadata.json delete mode 100644 awg-ui/src/tabs/migrate/migrate.css rename awg-ui/src/tabs/{ => settings}/apikeys/apikeys.css (55%) rename awg-ui/src/tabs/{ => settings}/apikeys/index.tsx (93%) rename awg-ui/src/tabs/{ => settings}/audit/audit.css (58%) rename awg-ui/src/tabs/{ => settings}/audit/index.tsx (96%) create mode 100644 awg-ui/src/tabs/settings/index.tsx create mode 100644 awg-ui/src/tabs/settings/metadata.json rename awg-ui/src/tabs/{ => settings}/migrate/index.tsx (95%) create mode 100644 awg-ui/src/tabs/settings/migrate/migrate.css create mode 100644 awg-ui/src/tabs/settings/prometheus/index.tsx create mode 100644 awg-ui/src/tabs/settings/prometheus/prometheus.css rename awg-ui/src/{components/ServerSettingsModal.tsx => tabs/settings/server/index.tsx} (81%) create mode 100644 awg-ui/src/tabs/settings/server/server.css create mode 100644 awg-ui/src/tabs/settings/settings.css create mode 100644 awg-ui/src/tokens.css create mode 100644 internal/ctrl/i1.go create mode 100644 internal/ctrl/i1_test.go create mode 100644 internal/ui/outdated_test.go diff --git a/.env b/.env index 2b2942b..9a10be2 100644 --- a/.env +++ b/.env @@ -11,4 +11,4 @@ BRAND=Forgetting CHANNEL=Beta -VERSION=0.4.7 +VERSION=0.5.0 diff --git a/README.md b/README.md index fd9ce47..7199e3b 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ URL в обоих случаях — адрес сервера и порт, вы ### Публичный API (`/api/v1`) -Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`. +Ключ создаётся в «Настройках», на вкладке «API-ключи», и уходит в заголовке `X-Api-Key`. Открытое значение показывается один раз при создании — на сервере лежит только SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля: `psk_key` и `pub_key` не покидают сервер. @@ -187,7 +187,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \ | Метод | Ответ | |---|---| -| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.7" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина | +| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.5.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина | | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | diff --git a/awg-agent/package.json b/awg-agent/package.json index f035b4e..fa7668f 100644 --- a/awg-agent/package.json +++ b/awg-agent/package.json @@ -1,6 +1,6 @@ { "name": "awg-agent", - "version": "0.4.7", + "version": "0.5.0", "private": true, "description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl", "scripts": { diff --git a/awg-ctrl/package.json b/awg-ctrl/package.json index bb3a481..4ed3b5c 100644 --- a/awg-ctrl/package.json +++ b/awg-ctrl/package.json @@ -1,6 +1,6 @@ { "name": "awg-control", - "version": "0.4.7", + "version": "0.5.0", "description": "", "main": "dist/index.js", "scripts": { diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 595810f..f29c8d1 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -15,7 +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"; +const MOCK_VERSION = "0.5.0"; // ── пользователи (vpn://) ── interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number } @@ -37,9 +37,10 @@ const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]) const U_ = (q: express.Request) => usersOf[srv(q)]; 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, version: "0.3.2" }, // отстаёт от панели + { id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.5.0" }, { 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 }, // старый агент: версию не шлёт + { id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: false, pending: false, version: "0.4.5" }, // старше 0.5.0: хаб не подключает + { id: 4, name: "Helsinki", ip: "192.0.2.44", gen: "3.1", online: true, pending: false, version: "0.5.1" }, // новее панели — жёлтый чип ]; 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"); @@ -53,8 +54,8 @@ 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] }, - { id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0] }, +const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 2, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1, 2] }, // все места заняты; Tokyo (2) ещё не получил устройства + { id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0, 1] }, // «все серверы» { 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 } : {}) }); @@ -75,9 +76,17 @@ app.get("/health", (q, r) => { }); // ── серверы ── +// Как MinNodeVersion в хабе: ноду старше хаб не подключает (в моке она просто offline + outdated) +const MIN_NODE = "0.5.0"; +const cmpVer = (a: string, b: string) => { + 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; +}; const nodeView = () => [ { 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, + outdated: !n.pending && (!n.version || cmpVer(n.version, MIN_NODE) < 0), 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, version: MOCK_VERSION, nodes: nodeView() })); @@ -106,23 +115,46 @@ const MON_LOGS = { install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 31d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."], remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."], }; -let monJob: { action: "install" | "remove"; start: number } | null = null; +// Как internal/ui/monitor.go: Prometheus у панели один. target — чей awg-ctrl ставит и удаляет его: +// где он уже стоит; если нигде — свой (панель + VPN) или ?server= (панель без VPN, MOCK_ROLE=core). +// Ход операции у каждого сервера свой (его awg-ctrl). Ставить можно, только если awg-ctrl сервера +// на связи и новый: Tokyo без связи, у Amsterdam старый агент (/api/monitor/job → 404) — у них +// can_install: false и команда вручную; у Frankfurt осталась неудачная прошлая установка. +type MonJob = { action: "install" | "remove"; start: number; lines?: string[]; exit?: number }; +const monJobs: Record = { + 1: { action: "install", start: now() * 1000 - 86400e3, exit: 100, lines: ["1/4 установка пакета prometheus", "Reading package lists…", + "E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1873 (unattended-upgr)", "E: Unable to acquire the dpkg frontend lock, is another process using it?"] }, +}; let monReady = false; +let monHost = 0; // где стоит (для панели без VPN — нода; у панели с VPN всегда 0, «этот сервер») +const monHostOf = (id: number) => ({ id, name: nodeView().find(n => n.id === id)?.name ?? `#${id}` }); const MON_MS = 6000; -const monRunning = () => !!monJob && Date.now() - monJob.start < MON_MS; -app.get("/ui/monitor", (_q, r) => { - if (monJob && !monRunning()) monReady = monJob.action === "install"; - let job = null; - if (monJob) { - const lines = MON_LOGS[monJob.action]; - const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length; - job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") }; +const monRunning = (j?: MonJob) => !!j && j.exit === undefined && Date.now() - j.start < MON_MS; +const monTarget = (q: express.Request) => monReady || !MOCK_CORE ? monHost : Number(q.query.server ?? 0); +const monCan = (id: number) => { const n = mnodes.find(x => x.id === id); return !n || (n.online && n.version !== null); }; +app.get("/ui/monitor", (q, r) => { + const target = monTarget(q), can = monCan(target), mj = monJobs[target]; + if (mj && mj.exit === undefined && !monRunning(mj)) { // операция дошла до конца + mj.exit = 0; + monReady = mj.action === "install"; + if (monReady) monHost = target; } - // MOCK_ROLE=core/retire — панель без VPN: Prometheus на ноде #0 (сервер, с которого она переехала) - r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", host: MOCK_CORE ? { id: 0, name: "VPN" } : null, job }); + let job = null; + if (can && mj) { + const lines = mj.lines ?? MON_LOGS[mj.action]; + const n = monRunning(mj) ? Math.max(1, Math.floor(((Date.now() - mj.start) / MON_MS) * lines.length)) : lines.length; + job = { action: mj.action, running: monRunning(mj), exit: monRunning(mj) ? null : mj.exit ?? 0, log: lines.slice(0, n).join("\n") }; + } + r.json({ ready: monReady, can_install: can, command: "awg-ctrl monitor install", host: MOCK_CORE ? monHostOf(target) : null, job }); }); for (const action of ["install", "remove"] as const) { - app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); }); + app.post(`/ui/monitor/${action}`, (q, r) => { + const target = monTarget(q), n = mnodes.find(x => x.id === target); + if (n && !n.online) return r.status(502).json({ error: "awg-ctrl недоступен" }); + if (n && n.version === null) return r.status(501).json({ error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" }); + if (!monRunning(monJobs[target])) monJobs[target] = { action, start: Date.now() }; + r.status(202).json({ running: true, action }); + }); } // Перенос панели (internal/ui/migrate.go). Мок — standalone: переносить можно, принимать нельзя; // MOCK_ROLE=core — наоборот (код «принимается» через 10 с, ещё через 10 с — «перезапуск»); @@ -183,19 +215,38 @@ app.get("/ui/monitor/overview", (q, r) => { const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6)); const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2)); const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]); - // Один сервер — тот, что выбран в сервер-баре (?server=): ядро (0) или нода - const id = Number(q.query.server ?? 0); - const k = id === 0 ? 0.7 : 0.3; - const down1 = scale(down, k), up1 = scale(up, k); const used = (a: [number, number][]) => a.reduce((n, [, v]) => n + v, 0) * step / 8; // бит/с × с → байты - if (id === 3) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] } }); - r.json({ range: q.query.range ?? "1h", step, from, to, - servers: [{ id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: down1.at(-1)![1], up: up1.at(-1)![1], online: id === 0 ? 7 : 3, - heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0, - cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30, - used_down: used(down1), used_up: used(up1) }], - total: { down: down1, up: up1, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0), - cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) } }); + // Ряды одного сервера: ядро (0) — основная нагрузка, нода Frankfurt (1) — поменьше + const one = (id: number) => { + const k = id === 0 ? 0.7 : 0.3; + const d = scale(down, k), u = scale(up, k); + return { + row: { id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: d.at(-1)![1], up: u.at(-1)![1], online: id === 0 ? 7 : 3, + heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0, + cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30, + used_down: used(d), used_up: used(u) }, + series: { down: d, up: u, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0), + cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) }, + }; + }; + const empty = { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] }; + // Без ?server= — все серверы (как monitor.go): ряды трафика, устройств и потерь суммируются, процессор + // и память — среднее. Метрики отдают ядро и Frankfurt; у Amsterdam (3) старый агент — его нет. + if (q.query.server === undefined) { + const all = [one(0), one(1)]; + const zip = (key: keyof typeof empty, avg: boolean) => all[0].series[key].map(([t], n) => { + const sum = all.reduce((acc, x) => acc + x.series[key][n][1], 0); + return [t, avg ? sum / all.length : sum] as [number, number]; + }); + return r.json({ range: q.query.range ?? "1h", step, from, to, servers: all.map(x => x.row), + total: { down: zip("down", false), up: zip("up", false), online: zip("online", false), heavy: zip("heavy", false), + drops: zip("drops", false), cpu: zip("cpu", true), mem: zip("mem", true) } }); + } + // Один сервер (?server=) + const id = Number(q.query.server); + if (id !== 0 && id !== 1) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: empty }); + const x = one(id); + r.json({ range: q.query.range ?? "1h", step, from, to, servers: [x.row], total: x.series }); }); // Prometheus: токен метрик и сам /metrics (без проверки токена — мок). @@ -211,7 +262,9 @@ app.delete("/ui/metrics-token", (_q, r) => { promTok = null; r.json(promView()); app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n')); // Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»). -const shapingOf: Record = {}; +// На сервере 0 деление канала включено сразу — чтобы был пример «фонового» приоритета: пользователь +// 10.9.0.2 и «MacBook Ивана» в мастер-ключе «Семья». +const shapingOf: Record = { 0: { enabled: true, downMbit: 100, upMbit: 50, auto: true } }; const shapingView = (id: number) => { const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true }; const on = settings.enabled; @@ -254,6 +307,25 @@ app.put("/api/torrent", (q, r) => { r.json(torrentView(id)); }); +// Маскировка первого пакета (I1): у каждого сервера своя; Frankfurt (1) — старая нода без маршрута (404). +// «Сменить» берёт случайный шаблон из примеров — как GenI1 в awg-ctrl, только без случайных полей. +const I1_SAMPLES = [ + { kind: "dns", label: "DNS-запрос к ya.ru", i1: "" }, + { kind: "quic", label: "QUIC Initial, 1250 байт", i1: "" }, + { kind: "stun", label: "STUN binding request", i1: "" }, + { kind: "dns", label: "DNS-запрос к www.google.com", i1: "" }, + { kind: "quic", label: "QUIC Initial, 1200 байт", i1: "" }, +]; +const maskingOf: Record = { 0: 0, 2: 1, 3: 2 }; +app.get("/api/masking", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(I1_SAMPLES[maskingOf[tsid(q)] ?? 3])); +app.post("/api/masking", (q, r) => { + const id = tsid(q); + if (id === 1) return r.status(404).json({ error: "Not found" }); + const cur = maskingOf[id] ?? 3; + maskingOf[id] = (cur + 1 + Math.floor(Math.random() * (I1_SAMPLES.length - 1))) % I1_SAMPLES.length; + r.json(I1_SAMPLES[maskingOf[id]]); +}); + app.get("/api/users", (q, r) => r.json({ users: U_(q) })); // Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый». // С меткой «торренты» — тоже фоновый (пока включено деление канала). diff --git a/awg-ui/nodes.ts b/awg-ui/nodes.ts index f053110..d5e6fa2 100644 --- a/awg-ui/nodes.ts +++ b/awg-ui/nodes.ts @@ -67,9 +67,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt"); try { if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) { + // CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI. const r = spawnSync("openssl", [ "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", - "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node", + "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=" + crypto.randomBytes(6).toString("hex"), ], { stdio: "ignore" }); if (r.status !== 0) throw new Error("openssl"); fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600); diff --git a/awg-ui/package.json b/awg-ui/package.json index 6a478af..6bd8ba3 100644 --- a/awg-ui/package.json +++ b/awg-ui/package.json @@ -1,6 +1,6 @@ { "name": "awg-ui", - "version": "0.4.7", + "version": "0.5.0", "private": true, "scripts": { "dev": "vite", diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index 21d570e..34c2920 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -6,97 +6,19 @@ LICENSE file in the root directory of this source tree. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body, #root { height: 100%; } -:root { - --primary: #6a45b8; - --on-primary: #ffffff; - --primary-container: #ebddff; - --on-primary-container: #5a2da8; - - --surface: #faf6f0; - --surface-bright: #fdfaf6; - --surface-container: #f0e8dd; - --surface-hi: #ece2d3; - --on-surface: #3a2e22; - - --outline: #d8cdbe; - --outline-variant: #ece2d3; - - --success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922; - --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; - - /* приглушённый «бровый» лейбл — заголовки таблицы, неактивная навигация */ - --label: #5a4a3a; - - /* state layers / тени — зависят от темы */ - --state-primary: rgba(106, 69, 184, .10); - --state-row: rgba(106, 69, 184, .035); - --state-neutral: rgba(58, 46, 34, .08); - --scrim: rgba(58, 46, 34, .45); - --shadow: rgba(58, 46, 34, .15); - - --font: system-ui, -apple-system, 'Inter', sans-serif; - --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - - --r-pill: 100px; - --r-card: 24px; - --r-table: 16px; - --r-logo: 9px; -} - -:root[data-theme="dark"] { - --primary: #cbb3ff; - --on-primary: #381b6c; - --primary-container: #52389e; - --on-primary-container: #ebddff; - - --surface: #1a1611; - --surface-bright: #2b251d; - --surface-container: #251f18; - --surface-hi: #302820; - --on-surface: #ede2d4; - - --outline: #4d4334; - --outline-variant: #342c20; - - --success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f; - --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; - - --label: #b6a994; - - --state-primary: rgba(203, 179, 255, .14); - --state-row: rgba(203, 179, 255, .05); - --state-neutral: rgba(237, 226, 212, .08); - --scrim: rgba(0, 0, 0, .55); - --shadow: rgba(0, 0, 0, .45); -} - body { font-family: var(--font); - font-size: 14px; - font-weight: 400; + font-size: var(--body-medium-size); line-height: var(--body-medium-line); + font-weight: var(--weight-regular); color: var(--on-surface); background: var(--surface); -webkit-font-smoothing: antialiased; - transition: background 0.2s ease, color 0.2s ease; + transition: background var(--duration-short4) var(--ease-standard), color var(--duration-short4) var(--ease-standard); } .app { min-height: 100vh; display: flex; flex-direction: column; position: relative; } -.logo-name { font-size: 17px; font-weight: 500; color: var(--on-surface); } +.logo-name { font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); } .login-screen { flex: 1; @@ -108,7 +30,7 @@ body { .login-card { width: 352px; background: var(--surface-bright); - border-radius: var(--r-card); + border-radius: var(--shape-extra-large); border: 1px solid var(--outline-variant); padding: 36px 32px 32px; display: flex; @@ -116,70 +38,89 @@ body { gap: 12px; } .login-sub { - font-size: 14px; + font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); margin-bottom: 4px; } .login-error { - font-size: 13px; - color: var(--danger-text); + font-size: var(--body-small-size); line-height: var(--body-small-line); + color: var(--error); text-align: center; } .field { - height: 44px; + height: 40px; padding: 0 16px; border: 1.5px solid var(--outline); - border-radius: var(--r-pill); + border-radius: var(--shape-full); background: var(--surface-bright); font-family: var(--font); - font-size: 14px; + font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); outline: none; - transition: border-color 0.15s; + transition: border-color var(--duration-short3) var(--ease-standard); width: 100%; } -.field:focus { border-color: var(--primary); } +.field:focus { border-color: var(--primary); box-shadow: inset 0 0 0 0.5px var(--primary); } .field::placeholder { color: var(--neutral-dot); } +/* ───── Кнопки (MD3 common buttons): 40px, подпись label-large, отступы 24px, + с иконкой слева — 16px слева. Иерархия: filled → tonal → outlined → text. ───── */ .btn { display: inline-flex; align-items: center; justify-content: center; - gap: 6px; - height: 44px; - padding: 0 20px; - border-radius: var(--r-pill); + gap: 8px; + height: 40px; + padding: 0 24px; + border-radius: var(--shape-full); border: none; font-family: var(--font); - font-size: 14px; - font-weight: 500; + font-size: var(--label-large-size); line-height: var(--label-large-line); + font-weight: var(--weight-medium); cursor: pointer; white-space: nowrap; - transition: opacity 0.12s, background 0.12s; + transition: background var(--duration-short2) var(--ease-standard), color var(--duration-short2) var(--ease-standard), border-color var(--duration-short2) var(--ease-standard); flex-shrink: 0; } -.btn:hover { opacity: 0.88; } -.btn:active { opacity: 0.74; } + +.btn:has(> svg:first-child) { padding-left: 16px; } .btn--primary { background: var(--primary); color: var(--on-primary); } .btn--tonal { background: var(--primary-container); color: var(--on-primary-container); border: none; } .btn--outline { background: transparent; - border: 1.5px solid var(--outline); + border: 1px solid var(--outline); color: var(--primary); } +/* Опасное действие: контурная кнопка роли error */ .btn--danger { background: transparent; - border: 1.5px solid var(--danger-border); - color: var(--danger-text); + border: 1px solid color-mix(in srgb, var(--error) 40%, transparent); + color: var(--error); } +/* Текстовая — наименее заметное действие: «Отмена», «Закрыть» в диалогах */ +.btn--text { + background: transparent; + color: var(--primary); + padding: 0 12px; +} +.btn--text:has(> svg:first-child) { padding: 0 16px 0 12px; } .btn--full { width: 100%; } -.btn--sq { width: 44px; padding: 0; flex-shrink: 0; } +/* :has() поднимает специфичность правила «с иконкой слева — 16px» выше .btn--sq — перебиваем явно, + иначе иконка в квадратной кнопке уезжает вправо */ +.btn--sq, .btn--sq:has(> svg:first-child) { width: 40px; padding: 0; flex-shrink: 0; } + +/* тач-цель ≥ 44px (design.md, 6) */ +@media (pointer: coarse) { + .btn, .field { height: 44px; } + .btn--sq { width: 44px; } +} /* icon-only circular button */ +/* Иконка-кнопка (MD3 standard icon button, размер XS) */ .btn-icon { - width: 30px; height: 30px; + width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent; @@ -188,20 +129,94 @@ body { align-items: center; justify-content: center; color: var(--primary); - transition: background 0.12s; + transition: background var(--duration-short2) var(--ease-standard); flex-shrink: 0; } -.btn-icon:hover { background: var(--state-primary); } -.btn-icon--danger { color: var(--danger-text); } -.btn-icon--danger:hover { background: rgba(179,74,58,.10); } +.btn-icon--danger { color: var(--error); } .btn-icon--neutral { color: var(--neutral-text); } -.btn-icon--neutral:hover { background: var(--state-neutral); } + +/* Сегментированные кнопки (MD3 segmented button): контурная «таблетка», сегменты через линию; + выбранный — заливкой secondary-container с галочкой. Выбор одного из 2–5 вариантов. */ +.segmented { + display: inline-flex; + border: 1px solid var(--outline); + border-radius: var(--shape-full); + overflow: hidden; + flex-shrink: 0; +} +.segmented-item { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + height: 38px; + padding: 0 16px; + border: none; + background: transparent; + color: var(--on-surface); + font-family: var(--font); + font-size: var(--label-large-size); line-height: var(--label-large-line); + font-weight: var(--weight-medium); + white-space: nowrap; + cursor: pointer; + transition: background var(--duration-short3) var(--ease-standard); +} +.segmented-item + .segmented-item { border-left: 1px solid var(--outline); } +.segmented-item[aria-checked="true"] { background: var(--secondary-container); color: var(--on-secondary-container); padding-left: 12px; } +.segmented-item:focus-visible { outline-offset: -3px; } +@media (pointer: coarse) { .segmented-item { height: 42px; } } + +/* ───── Состояния (MD3 state layers, design.md, 5) ───── + Поверх элемента — слой цвета его содержимого (currentColor; --state-layer переопределяет): + hover 8%, focus 10%, pressed 10%. Hover — только там, где есть указатель; на тач-устройствах + обратная связь — нажатие. Строки навигации инертны к нажатию (design.md, 5). */ +:is(.btn, .btn-icon, .theme-toggle, .awg-restart, .hamburger-btn, .rail-menu, .nav-item, .server-current, + .server-card--inactive, .server-pick, .mon-range, .mon-chart-toggle, .mk-caret, .mk-menu-item, .bs-opt, .segmented-item, .st-tab, .server-option) { + position: relative; +} +:is(.btn, .btn-icon, .theme-toggle, .awg-restart, .hamburger-btn, .rail-menu, .nav-item, .server-current, + .server-card--inactive, .server-pick, .mon-range, .mon-chart-toggle, .mk-caret, .mk-menu-item, .bs-opt, .segmented-item, .st-tab, .server-option)::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--state-layer, currentColor); + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-short2) var(--ease-standard); +} +@media (hover: hover) { + :is(.btn, .btn-icon, .theme-toggle, .awg-restart, .rail-menu, .nav-item, .server-current, + .server-card--inactive, .server-pick, .mon-range, .mon-chart-toggle, .mk-caret, .mk-menu-item, .bs-opt, .segmented-item, .st-tab, .server-option):hover:not(:disabled)::before { + opacity: var(--state-hover); + } +} +:is(.btn, .btn-icon, .theme-toggle, .awg-restart, .hamburger-btn, .rail-menu, .nav-item, .server-current, + .server-card--inactive, .server-pick, .mon-range, .mon-chart-toggle, .mk-caret, .mk-menu-item, .bs-opt, .segmented-item, .st-tab, .server-option):focus-visible::before { + opacity: var(--state-focus); +} +:is(.btn, .btn-icon, .theme-toggle, .awg-restart, .hamburger-btn, .rail-menu, .server-current, + .server-card--inactive, .server-pick, .mon-range, .mon-chart-toggle, .mk-caret, .mk-menu-item, .bs-opt, .segmented-item, .st-tab, .server-option):active:not(:disabled)::before { + opacity: var(--state-pressed); +} +/* Карточки и строки выбора подсвечиваются фирменным фиолетовым, а не цветом текста */ +.server-card--inactive, .server-pick:not(.server-pick--locked) { --state-layer: var(--primary); } +.server-pick--locked::before { display: none; } + +/* Фокус с клавиатуры — кольцо MD3: 3px роли secondary, отступ 2px. Поля ввода показывают фокус рамкой. */ +:is(button, a, summary, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible { + outline: 3px solid var(--secondary); + outline-offset: 2px; +} + +/* Недоступно (MD3): содержимое — on-surface 38%, контейнер — on-surface 12%; слоёв нет */ +:is(.btn, .btn-icon, .awg-restart, .theme-toggle):disabled { + color: var(--disabled-content); + cursor: default; +} +:is(.btn--primary, .btn--tonal):disabled { background: var(--disabled-container); } +:is(.btn--outline, .btn--danger, .server-add, .awg-restart):disabled { border-color: var(--disabled-container); } +.field:disabled { color: var(--disabled-content); border-color: var(--disabled-container); } .theme-toggle { - position: fixed; - top: 16px; - right: 16px; - z-index: 250; + position: relative; width: 40px; height: 40px; border-radius: 50%; @@ -213,34 +228,30 @@ body { justify-content: center; cursor: pointer; -webkit-tap-highlight-color: transparent; - transition: background 0.12s, border-color 0.12s, color 0.12s; + transition: background var(--duration-short2) var(--ease-standard), border-color var(--duration-short2) var(--ease-standard), color var(--duration-short2) var(--ease-standard); } -.theme-toggle:hover { border-color: var(--primary); color: var(--primary); } -/* Перезапуск AWG — pill-кнопка с подписью в правом верхнем углу, слева от темы */ +/* На экране входа тема — в правом верхнем углу */ +.theme-toggle--fixed { position: fixed; top: 16px; right: 16px; z-index: 250; } + +/* Перезапуск AWG — pill-кнопка с подписью в шапке страницы, слева от темы */ .awg-restart { - position: fixed; - top: 16px; - right: 64px; - z-index: 250; height: 40px; padding: 0 16px; gap: 6px; - border-radius: 100px; + border-radius: var(--shape-full); border: 1.5px solid var(--outline); background: var(--surface-bright); color: var(--on-surface); display: inline-flex; align-items: center; justify-content: center; - font-size: 13px; - font-weight: 500; + font-size: var(--label-large-size); line-height: var(--label-large-line); + font-weight: var(--weight-medium); cursor: pointer; -webkit-tap-highlight-color: transparent; - transition: background 0.12s, border-color 0.12s, color 0.12s; + transition: background var(--duration-short2) var(--ease-standard), border-color var(--duration-short2) var(--ease-standard), color var(--duration-short2) var(--ease-standard); } -.awg-restart:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); } -.awg-restart:disabled { opacity: 0.5; cursor: default; } .awg-restart.spinning svg { animation: awg-spin 0.8s linear infinite; } @keyframes awg-spin { to { transform: rotate(360deg); } } @@ -271,10 +282,22 @@ body { flex-shrink: 0; -webkit-tap-highlight-color: transparent; } -.hamburger-btn:active { background: var(--state-neutral); } +.rail-menu { + display: none; + width: 48px; height: 48px; + border: none; + background: transparent; + border-radius: 50%; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--on-surface-variant); + flex-shrink: 0; + -webkit-tap-highlight-color: transparent; +} .top-bar-title { - font-size: 20px; - font-weight: 500; + font-size: var(--title-large-size); line-height: var(--title-large-line); + font-weight: var(--weight-medium); color: var(--on-surface); } @@ -286,7 +309,7 @@ body { z-index: 300; opacity: 0; pointer-events: none; - transition: opacity 0.27s cubic-bezier(0.2, 0, 0, 1); + transition: opacity var(--duration-medium1) var(--ease-standard); } .drawer-scrim--visible { opacity: 1; @@ -323,23 +346,53 @@ body { .nav-item { display: flex; align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: var(--r-pill); - font-size: 14px; - font-weight: 400; - color: var(--label); - cursor: default; + gap: 12px; + width: 100%; + --nav-item-h: 36px; /* плотное меню под мышь; на тач-экранах — 44px, в rail — 56px (ниже) */ + min-height: var(--nav-item-h); + padding: 0 16px; + border: 0; + background: transparent; + border-radius: var(--shape-full); + font-family: var(--font); + font-size: var(--label-large-size); line-height: var(--label-large-line); + font-weight: var(--weight-regular); + color: var(--on-surface-variant); + text-align: left; + cursor: pointer; user-select: none; + -webkit-tap-highlight-color: transparent; } +.nav-icon { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; } +.nav-icon svg { display: block; } .nav-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; } -.nav-group-title { padding: 8px 12px 4px; font-size: 12px; color: var(--neutral-text); user-select: none; } +/* тач-цель ≥ 44px (design.md, 6) */ +@media (pointer: coarse) { .nav-item { --nav-item-h: 44px; } } +.nav-group-title { padding: 12px 16px 8px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); user-select: none; } .nav-item--active { background: var(--primary-container); color: var(--on-primary-container); - font-weight: 500; + font-weight: var(--weight-medium); } +/* Пункт, которого только что не было в меню («Монитор» сразу после установки Prometheus): + раскрывается до своей высоты (--nav-item-h), раздвигая соседей, и въезжает слева с проявлением. backwards — после + анимации ни max-height, ни transform не остаются. */ +.nav-item--enter { animation: nav-item-enter var(--duration-medium4) var(--ease-emphasized-decelerate) backwards; } +@keyframes nav-item-enter { + from { max-height: 0; min-height: 0; opacity: 0; transform: translateX(-24px); overflow: hidden; } + to { max-height: var(--nav-item-h); opacity: 1; transform: none; overflow: hidden; } +} +/* Пункт, который убрали из меню («Монитор» после удаления Prometheus): обратное движение — уезжает влево, + гаснет и схлопывается по высоте, соседи съезжаются. Быстрее появления (medium2, emphasized-accelerate); + forwards — схлопнутым он держится, пока App не уберёт его из меню (через те же 300 мс). */ +.nav-item--leave { animation: nav-item-leave var(--duration-medium2) var(--ease-emphasized-accelerate) forwards; pointer-events: none; } +@keyframes nav-item-leave { + from { max-height: var(--nav-item-h); opacity: 1; transform: none; overflow: hidden; } + to { max-height: 0; min-height: 0; opacity: 0; transform: translateX(-24px); overflow: hidden; } +} +@media (prefers-reduced-motion: reduce) { .nav-item--enter, .nav-item--leave { animation: none; } .nav-item--leave { display: none; } } + .sidebar-spacer { flex: 1; } .chip { @@ -347,8 +400,8 @@ body { align-items: center; gap: 6px; padding: 4px 10px; - border-radius: var(--r-pill); - font-size: 12px; + border-radius: var(--shape-full); + font-size: var(--body-small-size); line-height: var(--body-small-line); white-space: nowrap; } .chip-dot { @@ -360,7 +413,7 @@ body { .chip--online .chip-dot { background: var(--success-dot); } .chip--offline { background: var(--neutral-bg); color: var(--neutral-text); } .chip--offline .chip-dot { background: var(--neutral-dot); } -.chip--error { background: var(--error-bg); color: var(--error-text); } +.chip--error { background: var(--error-container); color: var(--on-error-container); } .chip--error .chip-dot { background: var(--error-dot); } .main { @@ -372,12 +425,128 @@ body { overflow: auto; min-width: 0; } +/* Заголовок-барабан (components/DrumTitle): обе надписи в одной ячейке сетки, рамка обрезает; + уходящая уезжает вверх (или вниз), новая въезжает с противоположной стороны. */ +.drum { display: inline-grid; overflow: hidden; vertical-align: top; } +.drum > span { grid-area: 1 / 1; } +.drum--roll .drum-in, +.drum--roll .drum-out { animation: var(--duration-medium2) var(--ease-standard) both; } +.drum--roll .drum-in { animation-name: drum-in; } +.drum--roll .drum-out { animation-name: drum-out; } +.drum--up { --drum-dir: 1; } +.drum--down { --drum-dir: -1; } +@keyframes drum-in { + from { opacity: 0; transform: translateY(calc(var(--drum-dir) * 100%)); } + to { opacity: 1; transform: none; } +} +@keyframes drum-out { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateY(calc(var(--drum-dir) * -100%)); } +} +@media (prefers-reduced-motion: reduce) { + .drum--roll .drum-in, .drum--roll .drum-out { animation: none; } + .drum--roll .drum-out { display: none; } +} + +/* Шапка страницы: заголовок слева, справа — выбор сервера, перезапуск AWG, тема */ +.page-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px 16px; } +.page-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; min-width: 0; } +.page-theme { display: inline-flex; } +.top-bar-end { margin-left: auto; display: inline-flex; padding-right: 4px; } .page-title { - font-size: 20px; - font-weight: 500; + font-size: var(--title-large-size); line-height: var(--title-large-line); + font-weight: var(--weight-medium); color: var(--on-surface); } +/* ───── Выбор сервера: кнопка с текущим сервером + выпадающий список (MD3 menu) ───── */ +.server-select { position: relative; max-width: 100%; min-width: 0; } +.server-current { + display: flex; + align-items: center; + gap: 12px; + min-height: 48px; + max-width: 100%; + padding: 4px 12px; + background: var(--surface-bright); + border: 1px solid var(--outline-variant); + border-radius: var(--shape-large); + font-family: var(--font); + color: var(--on-surface); + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.server-current--open { border-color: var(--primary-container); } +.server-current-info { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.server-current-info > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.server-current-count { + margin-left: 12px; + font-size: var(--body-small-size); line-height: var(--body-small-line); + color: var(--neutral-text); + white-space: nowrap; +} +.server-current-caret { display: inline-flex; color: var(--on-surface-variant); transition: transform var(--duration-short4) var(--ease-standard); } +.server-current--open .server-current-caret { transform: rotate(180deg); } + +.server-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 200; + width: max(100%, 380px); + max-width: calc(100vw - 32px); + max-height: min(70vh, 560px); + overflow-y: auto; + padding: 8px; + background: var(--surface-container); + border-radius: var(--shape-large); + box-shadow: var(--elevation-2); + transform-origin: top right; + animation: menu-in var(--duration-medium1) var(--ease-emphasized-decelerate); +} +@keyframes menu-in { + from { opacity: 0; transform: scaleY(.9) translateY(-4px); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: reduce) { .server-menu { animation: none; } } + +/* Выбор одного сервера внутри страницы (components/ServerSelect): та же кнопка и меню, что в шапке. + Строки — как серверы в меню шапки: точка, имя, IP; выбранная залита primary-container; недоступная — приглушена. */ +.server-option { + display: flex; align-items: center; gap: 10px; + width: 100%; + padding: 10px 12px; + border: 0; + border-radius: var(--shape-medium); + background: transparent; + font-family: var(--font); + color: var(--on-surface); + text-align: left; + cursor: pointer; +} +.server-option + .server-option { margin-top: 2px; } +.server-option--active { background: var(--primary-container); } +.server-option:disabled { cursor: default; opacity: .38; } +.server-option-nodot { visibility: hidden; } +.server-option-sub { margin-left: auto; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); white-space: nowrap; } +.server-option .server-current-info .server-option-sub { margin-left: 0; } + +/* Серверы в меню — строками списка; выбранный — заливкой primary-container, как пункт меню */ +.server-menu .server-bar { flex-direction: column; align-items: stretch; flex-wrap: nowrap; gap: 2px; } +.server-menu .server-card { + width: 100%; + border: 0; + border-radius: var(--shape-medium); + padding: 10px 8px 10px 12px; + background: transparent; +} +.server-menu .server-card--active { background: var(--primary-container); } +.server-menu .server-bar > .tip-wrap { width: 100%; margin-top: 6px; } +.server-menu .server-gen-wrap { width: auto; } /* на телефоне .tip-wrap растянут на всю ширину */ +.server-menu .server-add { width: 100%; } +.server-menu .awg-restart-mobile { margin-top: 4px; } + .server-bar { display: flex; align-items: center; @@ -389,7 +558,7 @@ body { align-items: center; gap: 10px; padding: 8px 14px 8px 10px; - border-radius: var(--r-card); + border-radius: var(--shape-extra-large); border: 1.5px solid var(--outline-variant); background: var(--surface-bright); cursor: default; @@ -397,8 +566,6 @@ body { } .server-card--active { border-color: var(--primary-container); } .server-card--inactive { cursor: pointer; } -.server-card--inactive:hover { background: var(--state-primary); } -.server-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } /* «Добавить сервер»: призрачная кнопка с пунктиром (design.md, 4.2) */ .server-add { @@ -406,48 +573,59 @@ body { border: 1.5px dashed var(--outline); color: var(--neutral-text); } -.server-add:disabled { opacity: .6; cursor: not-allowed; } /* Модалка добавления ноды */ -.node-scrim { - position: fixed; inset: 0; z-index: 60; +/* ───── Диалоги (MD3 basic dialog): контейнер surface-container-high без рамки, 28px, + отступы 24px, заголовок headline-small, действия справа внизу (закрытие — текстовой кнопкой). + Слой выше rail (400) и угловых кнопок (250), ниже снэкбара (500). ───── */ +.node-scrim, .qr-backdrop { + position: fixed; inset: 0; z-index: 450; background: var(--scrim); display: flex; align-items: center; justify-content: center; padding: 16px; + animation: backdrop-in var(--duration-short4) var(--ease-standard); +} +.node-modal, .qr-card { + background: var(--surface-container-high); + border-radius: var(--shape-extra-large); + box-shadow: var(--elevation-3); + animation: card-in var(--duration-medium2) var(--ease-emphasized-decelerate); } .node-modal { - width: 100%; max-width: 520px; + width: 100%; min-width: 280px; max-width: 560px; max-height: 100%; overflow-y: auto; - background: var(--surface-bright); - border: 1px solid var(--outline-variant); - border-radius: var(--r-card); - padding: 20px; - display: flex; flex-direction: column; gap: 12px; + padding: 24px; + display: flex; flex-direction: column; gap: 16px; } -.node-modal-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); } -.node-modal-hint { margin: 0; font-size: 12px; color: var(--neutral-text); } +/* Длинный диалог прокручивается целиком: без этого flex-элементы сжимаются и наезжают друг на друга */ +.node-modal > * { flex-shrink: 0; } +.node-modal-title { margin: 0; font-size: var(--headline-small-size); line-height: var(--headline-small-line); font-weight: var(--weight-regular); color: var(--on-surface); } +/* вложенные блоки — на самой светлой поверхности, чтобы читались на фоне диалога */ +:is(.node-modal, .qr-card) :is(.node-modal-cmd, .ss-status, .bs-row, .server-pick, .apikey-value) { + background: var(--surface-container-lowest); +} +.node-modal-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); } .node-modal-cmd { display: block; - font-family: var(--mono); font-size: 12px; + font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); background: var(--surface-container); - border-radius: var(--r-table); + border-radius: var(--shape-large); padding: 12px; word-break: break-all; user-select: all; } -.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } +.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; margin-top: 8px; } -/* Настройки сервера: деление канала */ +/* Настройки сервера (Настройки → «Сервер»): строки, поля, статус */ .ss-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; } .ss-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -.ss-setting-name { font-size: 14px; color: var(--on-surface); } +.ss-setting-name { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); } .ss-setting input[type="checkbox"] { width: 20px; height: 20px; flex: none; accent-color: var(--primary); } .ss-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .ss-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } -.ss-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: var(--surface-container); border-radius: var(--r-table); padding: 12px; } -.ss-mono { font-family: var(--mono); font-size: 12px; color: var(--neutral-text); word-break: break-all; } -.ss-section { margin: 4px 0 0; font-size: 14px; font-weight: 500; color: var(--on-surface); } -.ss-divider { border: 0; border-top: 1px solid var(--outline-variant); margin: 4px 0; } +.ss-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px; } +.ss-mono { font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); word-break: break-all; } +.ss-section { margin: 4px 0 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface); } .ss-seen { display: flex; flex-wrap: wrap; gap: 6px; } /* Тёплый чип «внимание»: канал занят, фоновый приоритет */ .chip--warm { background: var(--tertiary-container); color: var(--on-tertiary-container); } @@ -460,14 +638,14 @@ body { min-width: 0; } .server-card-name { - font-size: 14px; - font-weight: 500; + font-size: var(--title-small-size); + font-weight: var(--weight-medium); color: var(--on-surface); line-height: 1.2; } .server-card-ip { font-family: var(--mono); - font-size: 11px; + font-size: var(--body-small-size); color: var(--neutral-text); line-height: 1.2; } @@ -476,54 +654,40 @@ body { .server-chips .server-gen-wrap, .server-chips .server-gen-chip { margin-top: 0; } .server-ver-chip { font-family: var(--mono); - font-size: 11px; + font-size: var(--body-small-size); line-height: var(--body-small-line); 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-ver-chip--bad { background: var(--error-container); color: var(--on-error-container); } +.server-card-ip--bad { color: var(--error); } .server-gen-chip { align-self: flex-start; margin-top: 2px; - font-size: 11px; + font-size: var(--body-small-size); line-height: var(--body-small-line); padding: 2px 8px; } .server-card-peers { - font-size: 12px; + font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); background: var(--surface-container); padding: 2px 8px; - border-radius: var(--r-pill); + border-radius: var(--shape-full); white-space: nowrap; flex-shrink: 0; } -/* Переключатель сервера — скрыт на десктопе, показывается на мобильном */ -.server-toggle { - display: none; - position: relative; - width: 36px; height: 20px; - background: var(--neutral-bg); - border-radius: var(--r-pill); - flex-shrink: 0; - transition: background 0.2s; -} -.server-toggle--on { background: var(--success-bg); } -.server-toggle-thumb { - position: absolute; - top: 3px; left: 3px; - width: 14px; height: 14px; - background: var(--neutral-dot); +/* Статус сервера — точка с мягким ореолом того же тона: зелёная — на связи, серая — нет */ +.status-dot { + width: 8px; height: 8px; + margin: 0 3px; border-radius: 50%; - transition: transform 0.2s, background 0.2s; + flex-shrink: 0; + background: var(--neutral-dot); + box-shadow: 0 0 0 3px var(--neutral-bg); } -.server-toggle--on .server-toggle-thumb { - transform: translateX(16px); - background: var(--success-dot); -} - -/* Чип онлайн/офлайн на карточке сервера — виден на десктопе */ -.server-online-chip { display: inline-flex; } +.status-dot--online { background: var(--success-dot); box-shadow: 0 0 0 3px var(--success-bg); } /* Tooltip */ .tip-wrap { @@ -538,15 +702,29 @@ body { transform: translateX(-50%); background: var(--on-surface); color: var(--surface-bright); - font-size: 12px; + font-size: var(--body-small-size); line-height: var(--body-small-line); white-space: nowrap; padding: 5px 12px; - border-radius: var(--r-pill); + border-radius: var(--shape-full); pointer-events: none; opacity: 0; - transition: opacity 0.12s; + transition: opacity var(--duration-short2) var(--ease-standard); } .tip-wrap:hover::after { opacity: 1; } +/* Подсказки внутри прокручиваемой таблицы: прижаты к краю элемента — --start к левому (первые + колонки), --end к правому (последние), чтобы не вылезать за карточку и не обрезаться ею. Пока не + наведено — не участвуют в раскладке: скрытая подсказка в одну строку раздувала прокрутку таблицы + вбок, хотя её не видно. */ +.tip-wrap:is(.tip-wrap--start, .tip-wrap--end)::after { + display: none; + transform: none; + z-index: 5; + transition: opacity var(--duration-short2) var(--ease-standard), display var(--duration-short2) allow-discrete; +} +.tip-wrap--start::after { left: 0; } +.tip-wrap--end::after { left: auto; right: 0; } +.tip-wrap:is(.tip-wrap--start, .tip-wrap--end):is(:hover, :focus-within)::after { display: block; opacity: 1; } +@starting-style { .tip-wrap:is(.tip-wrap--start, .tip-wrap--end):is(:hover, :focus-within)::after { opacity: 0; } } .toolbar { display: flex; @@ -564,9 +742,9 @@ body { .table-card { background: var(--surface-bright); - border-radius: var(--r-table); + border-radius: var(--shape-large); border: 1px solid var(--outline-variant); - overflow: hidden; + overflow-x: auto; /* на ширине rail широкая таблица прокручивается, а не обрезается */ } table { width: 100%; @@ -574,9 +752,9 @@ table { } thead th { background: var(--surface-container); - color: var(--label); - font-weight: 500; - font-size: 13px; + color: var(--on-surface-variant); + font-weight: var(--weight-medium); + font-size: var(--title-small-size); line-height: var(--title-small-line); text-align: left; padding: 11px 14px; } @@ -588,15 +766,15 @@ tbody tr:hover { background: var(--state-row); } tbody td { padding: 11px 14px; - font-size: 13px; + font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); vertical-align: middle; } tbody td:first-child { padding-left: 20px; } tbody td:last-child { padding-right: 20px; } -.td-num { font-size: 12px; color: var(--neutral-dot); width: 32px; } -.td-mono { font-family: var(--mono); font-size: 12px; } +.td-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); width: 32px; } +.td-mono { font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); } .td-actions { width: 96px; } .actions { display: flex; align-items: center; gap: 2px; } @@ -605,7 +783,7 @@ tbody td:last-child { padding-right: 20px; } text-align: center; color: var(--neutral-text); padding: 48px 0 !important; - font-size: 14px; + font-size: var(--body-medium-size); line-height: var(--body-medium-line); } /* Карточки/метрики вкладки «Пользователи» → tabs/users/users.css */ @@ -615,72 +793,87 @@ tbody td:last-child { padding-right: 20px; } to { opacity: 1; } } @keyframes card-in { - from { opacity: 0; transform: scale(.92) translateY(12px); } + from { opacity: 0; transform: scale(.96) translateY(8px); } to { opacity: 1; transform: scale(1) translateY(0); } } -.qr-backdrop { - position: fixed; inset: 0; - background: var(--scrim); - display: flex; align-items: center; justify-content: center; - z-index: 300; - animation: backdrop-in 0.18s ease; -} +/* QR / одноразовый ключ — тот же диалог MD3 (общие стили выше), содержимое по центру */ .qr-card { - background: var(--surface-bright); - border-radius: var(--r-card); - border: 1px solid var(--outline-variant); - padding: 28px 28px 24px; - display: flex; flex-direction: column; align-items: center; gap: 14px; - width: 320px; - animation: card-in 0.22s cubic-bezier(.34,1.26,.64,1); + padding: 24px; + display: flex; flex-direction: column; align-items: center; gap: 16px; + width: 328px; } -.qr-name { font-size: 16px; font-weight: 500; color: var(--on-surface); } +.qr-name { font-size: var(--headline-small-size); line-height: var(--headline-small-line); font-weight: var(--weight-regular); color: var(--on-surface); text-align: center; overflow-wrap: anywhere; } .qr-img { - border-radius: 12px; + border-radius: var(--shape-medium); cursor: pointer; - transition: opacity 0.2s ease, box-shadow 0.35s ease; + transition: opacity var(--duration-short4) var(--ease-standard), box-shadow var(--duration-medium3) var(--ease-standard); box-shadow: 0 0 0 0px transparent; width: 280px; height: 280px; - image-rendering: crisp-edges; } .qr-img:hover { opacity: 0.88; } .qr-img:active { opacity: 1; box-shadow: 0 0 0 3px var(--primary-container), 0 0 0 5.5px var(--primary); - transition: opacity 0.1s ease, box-shadow 0.15s ease; + transition: opacity var(--duration-short2) var(--ease-standard), box-shadow var(--duration-short3) var(--ease-standard); } -.qr-hint { font-size: 12px; color: var(--neutral-text); text-align: center; } -.qr-actions { display: flex; gap: 8px; width: 100%; } -.qr-actions .btn { flex: 1; } +.qr-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); text-align: center; } +.qr-actions { display: flex; gap: 8px; width: 100%; justify-content: flex-end; flex-wrap: wrap; margin-top: 8px; } /* Открытый API-ключ → tabs/apikeys/apikeys.css */ +/* ───── Снэкбар (MD3): inverse-surface, 4px, минимум 48px, по центру снизу; на телефоне — + во всю ширину с полями 16px. Появляется снизу вверх. ───── */ .snack { position: fixed; - bottom: 28px; + bottom: 24px; left: 50%; - transform: translateX(-50%); - background: var(--on-surface); - color: var(--surface-bright); - font-size: 13px; - padding: 10px 22px; - border-radius: var(--r-pill); + translate: -50% 0; + display: flex; + align-items: center; + min-height: 48px; + max-width: min(560px, calc(100vw - 32px)); + padding: 14px 16px; + background: var(--inverse-surface); + color: var(--inverse-on-surface); + font-size: var(--body-medium-size); line-height: var(--body-medium-line); + border-radius: var(--shape-extra-small); z-index: 500; - white-space: nowrap; pointer-events: none; - box-shadow: 0 4px 16px var(--shadow); + box-shadow: var(--elevation-3); + animation: snack-in var(--duration-medium1) var(--ease-emphasized-decelerate); +} +@keyframes snack-in { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} +@media (prefers-reduced-motion: reduce) { + .snack, .node-modal, .qr-card, .node-scrim, .qr-backdrop { animation: none; } } .sidebar-version { - font-size: 11px; + font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); margin-top: 2px; } -@media (max-width: 640px) { - /* Top bar */ - .top-bar { display: flex; } +/* ───── Адаптивность: классы ширины окна MD3 (design.md, часть IV) ───── + compact < 600 — top app bar + модальный drawer, одна колонка, карточки вместо таблиц; + medium 600–839 — navigation rail, карточки сеткой; expanded 840–1199 — rail, таблицы; + large ≥ 1200 — постоянная боковая панель. */ + +/* compact */ +@media (max-width: 599px) { + /* Выбор сервера — во всю ширину */ + .server-select { flex: 1; } + .server-current { width: 100%; } + .server-current-count { margin-left: auto; } + .server-menu { width: 100%; left: 0; right: auto; transform-origin: top; } + + /* Top bar (MD3 small top app bar — 64px) */ + .top-bar { display: flex; height: 64px; } + .page-theme, .awg-restart { display: none; } + .page-actions { width: 100%; margin-left: 0; } .page-title { display: none; } /* Drawer scrim */ @@ -703,10 +896,10 @@ tbody td:last-child { padding-right: 20px; } height: 100%; width: 280px; z-index: 400; - border-radius: 0 var(--r-card) var(--r-card) 0; + border-radius: 0 var(--shape-extra-large) var(--shape-extra-large) 0; transform: translateX(-100%); - transition: transform 0.27s cubic-bezier(0.2, 0, 0, 1); - box-shadow: 4px 0 24px var(--shadow); + transition: transform var(--duration-medium1) var(--ease-standard); + box-shadow: var(--elevation-1); padding-top: 36px; } .sidebar--open { transform: translateX(0); } @@ -731,9 +924,6 @@ tbody td:last-child { padding-right: 20px; } } .server-card--inactive { border-color: var(--outline-variant); } .server-add { width: 100%; justify-content: center; } - .node-modal-actions .btn { flex: 1; } - .server-toggle { display: flex; } - .server-online-chip { display: none; } .tip-wrap { width: 100%; } @@ -750,19 +940,18 @@ tbody td:last-child { padding-right: 20px; } .toolbar .field { width: 100%; } .toolbar-btns { display: flex; + flex-wrap: wrap; /* третья кнопка («Серверы для всех») — на новую строку, а не за край экрана */ gap: 8px; } .toolbar-btns .btn { flex: 1; } - /* Таблица скрывается; карточки показываются в tabs/users/users.css */ - .table-card { display: none; } - /* QR модалка — по ширине экрана */ .qr-card { width: calc(100vw - 32px); max-width: 360px; - padding: 24px 20px 20px; } + /* снэкбар — во всю ширину с полями 16px */ + .snack { left: 16px; right: 16px; bottom: 16px; translate: none; max-width: none; } .qr-img { width: 100%; height: auto; aspect-ratio: 1; } } @@ -771,19 +960,17 @@ tbody td:last-child { padding-right: 20px; } .server-pick { display: flex; align-items: center; gap: 10px; background: var(--surface-container); - border-radius: 12px; + border-radius: var(--shape-medium); padding: 10px 14px; - font-size: 13px; + font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); cursor: pointer; } .server-pick input { accent-color: var(--primary); margin: 0; } -.server-pick:hover { background: var(--state-primary); } .server-pick--locked { cursor: default; } -.server-pick--locked:hover { background: var(--surface-container); } .server-pick-dot { padding: 0; background: none; } -.server-pick-name { font-weight: 500; } -.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); } +.server-pick-name { font-weight: var(--weight-medium); } +.server-pick-ip, .server-pick-gen { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); } .server-pick-ip { font-family: var(--mono); margin-left: auto; } /* Короткий идентификатор под именем (UUID мастер-ключа, ID устройства): @@ -792,7 +979,66 @@ tbody td:last-child { padding-right: 20px; } .copy-id { align-self: flex-start; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; - font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--neutral-text); + font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); font-weight: var(--weight-regular); color: var(--neutral-text); } .copy-id:hover { color: var(--primary); } -.copy-id:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; } +.copy-id:focus-visible { border-radius: var(--shape-extra-small); } + +/* compact и medium: таблицы → карточки (сами карточки — в CSS вкладок) */ +@media (max-width: 839px) { + .table-card { display: none; } +} + +/* medium и expanded: navigation rail 80px. Кнопка меню сверху раскрывает его поверх контента + в модальный drawer (тот же
-

{tab.label}

- {/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */} - {showServerBar && ( - setModal({})} - onJoin={n => setModal({ node: n })} - onDelete={deleteNode} - // Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного - onSettings={n => { pickServer(n.id); setSettingsFor(n); }} - onRestartAwg={restartAwg} - restarting={restarting} - showRestart={showRestart} - /> - )} + {/* Шапка страницы: заголовок слева; справа — выбор сервера, перезапуск AWG и тема. + На телефоне заголовок и тема — в верхнем баре, выбор сервера — во всю ширину. */} +
+

+
+ {/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают. + Текущий сервер — кнопкой, остальные — выпадающим списком под ней. */} + {showServerBar && ( +
+ + {serversOpen && ( +
+ { pickServer(id); setServersOpen(false); }} + onAdd={() => { setServersOpen(false); setModal({}); }} + onJoin={n => { setServersOpen(false); setModal({ node: n }); }} + onDelete={deleteNode} + // Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного + onSettings={n => { setServersOpen(false); pickServer(n.id); openTab('settings', 'server'); }} + onRestartAwg={restartAwg} + restarting={restarting} + showRestart={showRestart} + /> +
+ )} +
+ )} + {showRestart && serverId !== null && ( + + )} + {themeToggle} +
+
{serverId === null && showServerBar ? (

Серверов пока нет. Добавьте ноду: она сама подключится к этой панели. @@ -373,15 +499,6 @@ export default function App() { /> )} - {settingsFor && ( - setSettingsFor(null)} - showMsg={showMsg} - /> - )} - {msg &&

{msg}
} ); diff --git a/awg-ui/src/components/AddServerModal.tsx b/awg-ui/src/components/AddServerModal.tsx index 85041c5..390e0b2 100644 --- a/awg-ui/src/components/AddServerModal.tsx +++ b/awg-ui/src/components/AddServerModal.tsx @@ -85,6 +85,7 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs {error &&

{error}

}
+ {result ? ( )} -
diff --git a/awg-ui/src/components/DrumTitle.tsx b/awg-ui/src/components/DrumTitle.tsx new file mode 100644 index 0000000..b5619eb --- /dev/null +++ b/awg-ui/src/components/DrumTitle.tsx @@ -0,0 +1,19 @@ +// 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 } from 'react'; + +// Заголовок-«барабан»: при смене текста старый уходит гранью барабана, новый выкатывается на его +// место. order — позиция вкладки в меню: дальше по списку — барабан крутится вверх, обратно — вниз. +// Первый показ — без анимации. Стили — App.css, «Заголовок-барабан». +export default function DrumTitle({ text, order }: { text: string; order: number }) { + const [s, setS] = useState({ text, order, from: null as string | null, up: true, n: 0 }); + // Текст сменился — запоминаем прежний прямо в рендере, чтобы новый не мелькнул без анимации + if (s.text !== text) setS({ text, order, from: s.text, up: order >= s.order, n: s.n + 1 }); + return ( + + {s.from !== null && } + {text} + + ); +} diff --git a/awg-ui/src/components/ServerBar.tsx b/awg-ui/src/components/ServerBar.tsx index e09a530..af3db9a 100644 --- a/awg-ui/src/components/ServerBar.tsx +++ b/awg-ui/src/components/ServerBar.tsx @@ -1,27 +1,31 @@ // 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, cmpVersion } from '../lib/shared'; +import { type NodeInfo, genLabel, cmpVersion, MIN_NODE_VERSION } from '../lib/shared'; import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons'; // Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и // (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает // серверы, между которыми переключается содержимое вкладки (пиры / абоненты). -// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию -// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить». +// Версия Forgetting на сервере, три состояния: совпадает с панелью — нейтральный чип; нода отстаёт +// от панели (но не старше MIN_NODE_VERSION) или новее неё — жёлтый, стоит обновить; старше +// MIN_NODE_VERSION или не сообщает версию (агент старше 0.3.4) — красный: хаб такую ноду не подключает. function versionChip(n: NodeInfo, panel: string | null) { if (n.pending) return null; - let tip = '', warn = false; + let tip = '', level: '' | 'warn' | 'bad' = ''; if (!n.version) { - warn = true; - tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)'; + level = 'bad'; + tip = `Версия неизвестна: нода старше 0.3.4, панель с ней не работает — обнови её до ${MIN_NODE_VERSION} (install.sh → «Обновить»)`; + } else if (!n.local && cmpVersion(n.version, MIN_NODE_VERSION) < 0) { + level = 'bad'; + tip = `Слишком старая версия: панель с ней не работает, нужна не ниже ${MIN_NODE_VERSION} — обнови ноду (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}) — обнови панель`; } + if (d < 0) { level = 'warn'; tip = `Устарела: панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; } + else if (d > 0) { level = 'warn'; tip = `Нода новее панели (${panel}) — обнови панель`; } } const chip = ( - + {n.version ? `v${n.version}` : 'v?.?.?'} ); @@ -60,18 +64,17 @@ export default function ServerBar({ onClick={() => onSelect(n.id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }} > - {/* toggle — виден на мобильном вместо чипа */} - diff --git a/awg-ui/src/tabs/users/users.css b/awg-ui/src/tabs/users/users.css index 371ae3d..a795890 100644 --- a/awg-ui/src/tabs/users/users.css +++ b/awg-ui/src/tabs/users/users.css @@ -8,9 +8,9 @@ LICENSE file in the root directory of this source tree. .user-cards-list { display: none; flex-direction: column; gap: 12px; } -.users-count { font-size: 12px; color: var(--neutral-text); } +.users-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); } .empty-cards { - font-size: 14px; + font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; @@ -18,7 +18,7 @@ LICENSE file in the root directory of this source tree. .user-card { background: var(--surface-bright); border: 1px solid var(--outline-variant); - border-radius: var(--r-card); + border-radius: var(--shape-extra-large); padding: 16px; display: flex; flex-direction: column; @@ -36,10 +36,10 @@ LICENSE file in the root directory of this source tree. gap: 8px; min-width: 0; } -.user-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; } +.user-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; } .user-card-name { - font-size: 16px; - font-weight: 500; + font-size: var(--title-medium-size); line-height: var(--title-medium-line); + font-weight: var(--weight-medium); color: var(--on-surface); overflow: hidden; text-overflow: ellipsis; @@ -52,20 +52,23 @@ LICENSE file in the root directory of this source tree. gap: 8px; color: var(--neutral-text); } -.user-card-ip-text { font-family: var(--mono); font-size: 13px; } +.user-card-ip-text { font-family: var(--mono); font-size: var(--body-medium-size); line-height: var(--body-medium-line); } +/* чипы приоритета и торрентов — рядом с IP, как в карточке устройства мастер-ключа (на телефоне + .tip-wrap по умолчанию во всю ширину и уводил чип на отдельную строку) */ +.user-card-ip .tip-wrap { width: auto; } .user-card-metrics { display: flex; gap: 8px; } .metric-block { flex: 1; background: var(--surface-container); - border-radius: var(--r-table); + border-radius: var(--shape-large); padding: 10px 14px; display: flex; flex-direction: column; gap: 4px; } -.metric-label { font-size: 12px; color: var(--neutral-text); } -.metric-value { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--on-surface); } +.metric-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); } +.metric-value { font-family: var(--mono); font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); } .user-card-actions { display: flex; gap: 8px; } .user-card-actions .btn--outline { flex: 1; } @@ -73,9 +76,14 @@ LICENSE file in the root directory of this source tree. /* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */ .user-gen { margin-left: 8px; vertical-align: middle; } -/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */ -@media (max-width: 640px) { - .user-cards-list { display: flex; } +/* compact и medium (< 840px): таблица скрывается (.table-card в App.css), показываются карточки — + в одну колонку на телефоне, сеткой на планшете. */ +@media (max-width: 839px) { + .user-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } + /* счётчик и «пусто» — на всю строку сетки */ + :is(.users-count, .empty-cards) { grid-column: 1 / -1; } +} +@media (max-width: 599px) { /* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */ .user-gen { width: auto; } } diff --git a/awg-ui/src/tokens.css b/awg-ui/src/tokens.css new file mode 100644 index 0000000..1c2ca0d --- /dev/null +++ b/awg-ui/src/tokens.css @@ -0,0 +1,164 @@ +/* +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. +*/ +/* Токены дизайн-системы Material 3 (design.md, часть I). Компоненты берут значения только + отсюда — без литеральных цветов, размеров шрифта, радиусов и длительностей. + Цвета — тёплая палитра Forgetting, разложенная по ролям MD3: роли, которых раньше не было + (secondary, tertiary, лестница surface-container, inverse), достроены в тех же оттенках. */ + +:root { + /* ── Цвет: роли MD3 ── */ + --primary: #6a45b8; + --on-primary: #ffffff; + --primary-container: #ebddff; + --on-primary-container: #5a2da8; + --inverse-primary: #cbb3ff; + + --secondary: #645a73; + --on-secondary: #ffffff; + --secondary-container: #e8dff5; + --on-secondary-container: #4c425a; + + --tertiary: #8b5a1f; + --on-tertiary: #ffffff; + --tertiary-container: #ffdcad; + --on-tertiary-container: #7a4a14; + + --error: #ba1a1a; + --on-error: #ffffff; + --error-container: #ffdad6; + --on-error-container: #410002; + + --surface: #faf6f0; + --surface-dim: #e2d9cc; + --surface-bright: #fdfaf6; /* рабочая зона: карточки, поля, диалоги */ + --surface-container-lowest: #ffffff; + --surface-container-low: #f5efe6; + --surface-container: #f0e8dd; + --surface-container-high: #ece2d3; + --surface-container-highest: #e4dac9; + --on-surface: #3a2e22; + --on-surface-variant: #5a4a3a; + --inverse-surface: #3a2e22; + --inverse-on-surface: #fdfaf6; + + --outline: #d8cdbe; + --outline-variant: #ece2d3; + + /* ── Цвет: статусы (custom colors MD3), тёплый подтон ── */ + --success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922; + --neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85; + --error-dot: #ba1a1a; + --warn-dot: #e08a2c; + + /* ── State layers: непрозрачность слоя MD3 (цвет слоя — цвет содержимого, App.css) ── */ + --state-hover: .08; + --state-focus: .10; + --state-pressed: .10; + --state-dragged: .16; + /* недоступные элементы: содержимое и контейнер (on-surface 38% / 12%) */ + --disabled-content: color-mix(in srgb, var(--on-surface) 38%, transparent); + --disabled-container: color-mix(in srgb, var(--on-surface) 12%, transparent); + --state-row: rgba(106, 69, 184, .035); + + --scrim: rgba(58, 46, 34, .45); + --shadow: rgba(58, 46, 34, .15); + + /* ── Типографика: шкала MD3 (размер / интерлиньяж); веса — только 400 и 500. + label-small (11px) не заводим: минимальный размер шрифта — 12px (design.md, 6) ── */ + --font: system-ui, -apple-system, 'Inter', sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + --weight-regular: 400; + --weight-medium: 500; + + --headline-small-size: 24px; --headline-small-line: 32px; + --title-large-size: 22px; --title-large-line: 28px; /* emphasized: вес 500 */ + --title-medium-size: 16px; --title-medium-line: 24px; + --title-small-size: 14px; --title-small-line: 20px; + --body-large-size: 16px; --body-large-line: 24px; + --body-medium-size: 14px; --body-medium-line: 20px; + --body-small-size: 12px; --body-small-line: 16px; + --label-large-size: 14px; --label-large-line: 20px; + --label-medium-size: 12px; --label-medium-line: 16px; + + /* ── Форма: шкала скруглений MD3 ── */ + --shape-extra-small: 4px; + --shape-small: 8px; + --shape-medium: 12px; + --shape-large: 16px; + --shape-extra-large: 28px; + --shape-full: 9999px; + + /* ── Движение: кривые и длительности MD3 ── */ + --ease-standard: cubic-bezier(.2, 0, 0, 1); + --ease-standard-decelerate: cubic-bezier(0, 0, 0, 1); + --ease-standard-accelerate: cubic-bezier(.3, 0, 1, 1); + --ease-emphasized-decelerate: cubic-bezier(.05, .7, .1, 1); + --ease-emphasized-accelerate: cubic-bezier(.3, 0, .8, .15); + --duration-short1: 50ms; + --duration-short2: 100ms; + --duration-short3: 150ms; + --duration-short4: 200ms; + --duration-medium1: 250ms; + --duration-medium2: 300ms; + --duration-medium3: 350ms; + --duration-medium4: 400ms; + + /* ── Элевация MD3: уровни 1–3 (тени цвета --shadow) ── */ + --elevation-1: 0 1px 2px var(--shadow), 0 1px 3px 1px var(--shadow); + --elevation-2: 0 1px 2px var(--shadow), 0 2px 6px 2px var(--shadow); + --elevation-3: 0 1px 3px var(--shadow), 0 4px 8px 3px var(--shadow); +} + +:root[data-theme="dark"] { + --primary: #cbb3ff; + --on-primary: #381b6c; + --primary-container: #52389e; + --on-primary-container: #ebddff; + --inverse-primary: #6a45b8; + + --secondary: #cec1de; + --on-secondary: #352c43; + --secondary-container: #483a55; + --on-secondary-container: #ebddff; + + --tertiary: #f2bd7c; + --on-tertiary: #4a2800; + --tertiary-container: #4a3218; + --on-tertiary-container: #ffdcad; + + --error: #ffb4ab; + --on-error: #690005; + --error-container: #5c1414; + --on-error-container: #ffb4ab; + + /* surface-bright светлее канона MD3 (тон 15 вместо 24): рабочая зона в тёмной теме + чуть приподнята над фоном, а не выбелена */ + --surface: #1a1611; + --surface-dim: #1a1611; + --surface-bright: #2b251d; + --surface-container-lowest: #14100b; + --surface-container-low: #201a14; + --surface-container: #251f18; + --surface-container-high: #302820; + --surface-container-highest: #3a332a; + --on-surface: #ede2d4; + --on-surface-variant: #b6a994; + --inverse-surface: #ede2d4; + --inverse-on-surface: #2b251d; + + --outline: #4d4334; + --outline-variant: #342c20; + + --success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f; + --neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085; + --error-dot: #ff5449; + --warn-dot: #ffb76b; + + --state-row: rgba(203, 179, 255, .05); + + --scrim: rgba(0, 0, 0, .55); + --shadow: rgba(0, 0, 0, .45); +} diff --git a/awg-ui/vite.config.ts b/awg-ui/vite.config.ts index d797931..4055173 100644 --- a/awg-ui/vite.config.ts +++ b/awg-ui/vite.config.ts @@ -4,6 +4,8 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +const api = `http://localhost:${process.env.MOCK_PORT || 8080}`; + export default defineConfig({ // Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в // подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же @@ -12,11 +14,11 @@ export default defineConfig({ plugins: [react()], server: { proxy: { - '/api': 'http://localhost:8080', - '/ui': 'http://localhost:8080', - '/health': 'http://localhost:8080', - '/login': 'http://localhost:8080', - '/logout': 'http://localhost:8080', + '/api': api, + '/ui': api, + '/health': api, + '/login': api, + '/logout': api, }, }, }); diff --git a/cli/package.json b/cli/package.json index 855ea6a..c697d12 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "awg-cli", - "version": "0.4.7", + "version": "0.5.0", "private": true, "dependencies": { "tsx": "^4.7.0", diff --git a/install.sh b/install.sh index cc055a2..e61e8c1 100644 --- a/install.sh +++ b/install.sh @@ -15,37 +15,90 @@ else GRN=""; YLW=""; RED=""; ACC=""; BLD=""; DIM=""; NC="" fi +# Терминал ли на выводе — запоминаем до перенаправления в лог (после него stdout — уже труба). +# Ширина — чтобы строка спиннера не переносилась. +TTY_OUT="" +[[ -t 1 ]] && TTY_OUT="y" +COLS=$(stty size 2>/dev/null &2; exit 1; } info() { echo -e " ${DIM}→ $*${NC}"; } skip() { echo -e " ${DIM}– $*${NC}"; } -rule() { echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"; } -# Заголовок шага: step "1/6 AmneziaWG" → «▍ AmneziaWG 1 из 6»; без номера — просто заголовок. +# Заголовок шага — полоса прогресса фиксированной ширины, затем название (так названия стоят +# в один столбец): step "4/6 Запуск AWG" → «━━━━━━━━──── Запуск AWG 4/6». Шаг без номера +# (проверки, вопросы) — с пустой полосой той же ширины. +STEP_W=12 step() { + local n=0 t=1 title="$*" full="" left="" bar i f if [[ "$1" =~ ^([0-9]+)/([0-9]+)\ +(.*)$ ]]; then - echo -e "\n${ACC}▍${NC}${BLD} ${BASH_REMATCH[3]}${NC} ${DIM}${BASH_REMATCH[1]} из ${BASH_REMATCH[2]}${NC}" + n="${BASH_REMATCH[1]}"; t="${BASH_REMATCH[2]}"; title="${BASH_REMATCH[3]}" + fi + # Две половины собираем отдельно: срез ${bar:0:f} в C-локали режет байты, а «━» — три байта. + f=$(( n * STEP_W / t )) + for (( i = 0; i < STEP_W; i++ )); do + if (( i < f )); then full+="━"; else left+="─"; fi + done + bar="${ACC}${full}${NC}${DIM}${left}${NC}" + if (( n > 0 )); then + echo -e "\n ${bar} ${BLD}${title}${NC} ${DIM}${n}/${t}${NC}" else - echo -e "\n${ACC}▍${NC}${BLD} $*${NC}" + echo -e "\n ${bar} ${BLD}${title}${NC}" fi } +# Карточка — строки с цветной полосой слева (правой рамки нет: ширину кириллицы в C-локали +# bash не посчитает, и рамка бы поехала). card_line ЦВЕТ "текст". +card_line() { echo -e " ${1}┃${NC} ${2:-}"; } # Приглашение к вводу — для read -p. Q=" ${ACC}›${NC} " -# Вывод команд — полностью, на экран и в лог, приглушённым блоком «│» под шагом. -# run cmd … — выполнить, показать всё, что она пишет (stdout+stderr); код возврата — её. +# Вывод команд. В лог — всегда полностью, приглушённым блоком «│». На экране (терминал) — одна +# строка: спиннер, последняя строка вывода и время; упала команда — её последние 30 строк. +# Без терминала или с VERBOSE=1 — весь вывод и на экран, как в логе. +# run cmd … — выполнить; код возврата — её. # … | logblock — то же оформление для готового текста (хвост make.log, dmesg). # Переменные перед вызовом функции bash передаёт и в неё, и в её команды: # DEBIAN_FRONTEND=noninteractive run apt-get install -y … +# Дескрипторы (открываются ниже, вместе с логом): 3 — только в лог, 4 — только на экран. +# Командам 3 закрываем: демон, унаследовавший его, держал бы лог открытым после выхода. logblock() { local line while IFS= read -r line || [[ -n "$line" ]]; do printf ' %s│ %s%s\n' "$DIM" "$line" "$NC" done } +SPIN=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) run() { - "$@" 2>&1 | logblock - return "${PIPESTATUS[0]}" + if [[ -z "$TTY_OUT" || -n "${VERBOSE:-}" ]] || ! { true >&3; } 2>/dev/null; then + "$@" 2>&1 3>&- | logblock + return "${PIPESTATUS[0]}" + fi + local out pid rc=0 i=0 t0=$SECONDS last el w name="$1" + [[ "$name" == */* ]] && name="${name##*/}" + [[ -n "${2:-}" && "$2" != -* ]] && name+=" $2" + w=$(( COLS - ${#name} - 20 )); (( w < 10 )) && w=10 + out=$(mktemp) + printf ' %s$ %s%s\n' "$DIM" "$*" "$NC" >&3 + "$@" > "$out" 2>&1 3>&- & + pid=$! + while kill -0 "$pid" 2>/dev/null; do + el=$(( SECONDS - t0 )) + last=$(tail -n 1 "$out" 2>/dev/null | tr -d '\r\033\t' | cut -c1-"$w" || true) + printf '\r\033[K %s%s%s %s %s%s %d:%02d%s' "$ACC" "${SPIN[i++ % 10]}" "$NC" "$name" "$DIM" "$last" $(( el / 60 )) $(( el % 60 )) "$NC" >&4 + sleep 0.12 + done + wait "$pid" || rc=$? + printf '\r\033[K' >&4 + logblock < "$out" >&3 + (( rc != 0 )) && printf ' ✗ код %d\n' "$rc" >&3 + if (( rc != 0 )); then + printf ' %s✗%s %s — код %d, последние строки (весь вывод — в логе):\n' "$RED" "$NC" "$name" "$rc" >&4 + tail -n 30 "$out" | logblock >&4 + fi + rm -f "$out" + return "$rc" } # Случайная строка из [a-zA-Z0-9]. Вход конечный: при `tr < /dev/urandom | head` tr получает @@ -92,15 +145,22 @@ LOGFILE="/var/log/awg-install-$(date +%Y%m%d-%H%M%S).log" # LC_ALL=C: sed режет байты, а не символы — на битом UTF-8 из чужого вывода (make.log, dmesg) он # не спотыкается. tee -p: если запись лога вдруг умрёт, tee не падает от SIGPIPE и продолжает # писать на экран — иначе вслед за ним молча умер бы и сам установщик. -exec > >(tee -p >(LC_ALL=C sed -u -e $'s/\033\\[[0-9;]*[A-Za-z]//g' -e $'s/.*\r//' >> "$LOGFILE")) 2>&1 +# Три потока: 4 — сам терминал (спиннер run и хвост упавшей команды — только на экран); +# 3 — только лог (полный вывод команд под спиннером); stdout/stderr — и туда, и туда (tee). +exec 4>&1 +exec 3> >(LC_ALL=C sed -u -e $'s/\033\\[[0-9;]*[A-Za-z]//g' -e $'s/.*\r//' >> "$LOGFILE") +LOG_SED_PID="${!:-}" +exec > >(tee -p /dev/fd/3) 2>&1 LOG_PID="${!:-}" -# Дождаться, пока tee допишет хвост, — иначе последние строки вылезут уже после приглашения шелла. -trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT +# Дождаться, пока tee и sed допишут хвост, — иначе последние строки вылезут уже после приглашения +# шелла. sed ждём не дольше 2 с: если какой-то процесс всё же унаследовал fd 3, выход не повиснет. +trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true; exec 3>&- + for _ in {1..20}; do kill -0 "$LOG_SED_PID" 2>/dev/null || break; sleep 0.1; done' EXIT # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # При бампе версии правь оба места. -VERSION="0.4.7" +VERSION="0.5.0" CHANNEL="${CHANNEL:-Beta}" # Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник @@ -158,25 +218,57 @@ MTU="1376" BIN="$PROJECT/awg-control" echo -echo -e " ${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" -echo -e " ${DIM}Панель управления VPN на AmneziaWG — установка${NC}" -echo -e " ${DIM}Лог: $LOGFILE${NC}" -rule +card_line "$ACC" +card_line "$ACC" "${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" +card_line "$ACC" "${DIM}Панель управления VPN на AmneziaWG — установка${NC}" +card_line "$ACC" +card_line "$ACC" "${DIM}лог $LOGFILE${NC}" +card_line "$ACC" # Поверх существующей установки: обновить (настройки, пользователи и роль остаются) # или переустановить с нуля. Спрашиваем первым — от ответа зависит, спрашивать ли роль. +# +# Установку старше MIN_UPGRADE_VERSION не обновить и не перенести: с ней несовместимы и данные, +# и выданные ключи. Для неё — только полная переустановка со сбросом всех данных (WIPE=y): +# пользователи, мастер-ключи, ключи API, ключи сервера и ноды — всё заново. Старые базы уходят +# в бэкап (*.bak-…), как при обычной переустановке, но перенести их нельзя. Версия — из .env +# прежней установки (он есть с 0.2.0); нет его — версия неизвестна и тоже считается устаревшей. +MIN_UPGRADE_VERSION="0.5.0" +version_lt() { [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]]; } INSTALL_MODE="fresh" +WIPE="n" +PREV_VERSION="" if [[ -d "$PROJECT" ]]; then + PREV_VERSION=$(sed -n 's/^VERSION=//p' "$PROJECT/.env" 2>/dev/null | head -1 | tr -d "\"' " || true) echo - warn "Уже установлено в $PROJECT" - echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" - echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" - ask INST_CHOICE "Выбери [1/2, Enter — 1]: " - case "${INST_CHOICE:-1}" in - 1) INSTALL_MODE="update" ;; - 2) INSTALL_MODE="fresh" ;; - *) fail "Неверный выбор: введи 1 или 2" ;; - esac + if [[ -z "$PREV_VERSION" ]] || version_lt "${PREV_VERSION%%-*}" "$MIN_UPGRADE_VERSION"; then + card_line "$RED" + card_line "$RED" "${RED}${BLD}✗ Установлена устаревшая версия ${PREV_VERSION:-(неизвестна)}${NC}" + card_line "$RED" + card_line "$RED" "Обновить её до ${VERSION} нельзя: версии старше ${MIN_UPGRADE_VERSION} нужна полная переустановка." + card_line "$RED" "Будут удалены все ключи и данные:" + card_line "$RED" " ${DIM}·${NC} пользователи и их vpn:// ключи" + card_line "$RED" " ${DIM}·${NC} мастер-ключи и ссылки sen://, ключи API" + card_line "$RED" " ${DIM}·${NC} ключи сервера и подключение к панели (ноду придётся добавить заново)" + card_line "$RED" + card_line "$RED" "Выданные ключи перестанут работать — клиентам нужно будет выдать новые." + card_line "$RED" "${DIM}Старые базы останутся в бэкапе (*.bak-…), но перенести их в ${VERSION} нельзя.${NC}" + card_line "$RED" + echo + ask WIPE_YN "Переустановить с удалением всех ключей? [y/N]: " + [[ "${WIPE_YN:-n}" =~ ^[Yy]$ ]] || { echo " Отменено — ничего не изменено."; exit 0; } + WIPE="y" + else + warn "Уже установлено в $PROJECT ${DIM}(версия ${PREV_VERSION})${NC}" + echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" + echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" + ask INST_CHOICE "Выбери [1/2, Enter — 1]: " + case "${INST_CHOICE:-1}" in + 1) INSTALL_MODE="update" ;; + 2) INSTALL_MODE="fresh" ;; + *) fail "Неверный выбор: введи 1 или 2" ;; + esac + fi echo fi @@ -371,7 +463,9 @@ DB_EXISTS="n" # - S4 добавляется к каждому пакету данных — держим маленьким, как прежние 14 # - H1–H4: непересекающиеся диапазоны в [10^8, 2^31−1] — далеко от 1–4 (типы сообщений # WireGuard) и не выше прежних значений (некоторые клиенты разбирают их как int32) -# - I1–I5 НЕ трогать: в vpn:// ключ уходят пустыми плейсхолдерами +# - I1 здесь не задаётся и в awg1.conf не пишется: он нужен только клиентам, его выбирает +# awg-ctrl (свой у каждого сервера, хранится в users.db — KEEP_DATA его сохраняет; +# internal/ctrl/i1.go). I2–I5 в vpn:// ключ уходят пустыми плейсхолдерами # Прежние постоянные значения: ими собраны ключи установок до рандомизации — нужны, если # при KEEP_DATA=y awg1.conf потерялся. LEGACY_AWG_PORT="47619" @@ -632,9 +726,10 @@ if [[ "$INSTALL_MODE" == "update" ]]; then install_command start_and_status - rule - echo -e " ${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${ROLE_LABEL}${NC}" - rule + echo + card_line "$GRN" + card_line "$GRN" "${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${BRAND} · ${ROLE_LABEL}${NC}" + card_line "$GRN" echo exit 0 fi @@ -683,7 +778,9 @@ fi # vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними. # Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи). KEEP_DATA="n" -if [[ "$ROLE" == "core" ]]; then +if [[ "$WIPE" == "y" ]]; then + : # устаревшая установка: сохранять нечего, сброс уже подтверждён +elif [[ "$ROLE" == "core" ]]; then if [[ -f "$UI_DB_FILE" ]]; then echo warn "Найдены данные панели: $UI_DB_FILE" @@ -793,7 +890,7 @@ if [[ "$ROLE" == "node" ]]; then JOIN="${JOIN:-}" # Переустановка ноды: ключ ноды на месте, панель её уже знает — можно не выпускать # новую строку, а оставить прежнее подключение. - if [[ -z "$JOIN" && "$PREV_ROLE" == "node" && -n "$(prev_val CORE_HOST)" && -f "$NODE_KEY_FILE" ]]; then + if [[ -z "$JOIN" && "$WIPE" != "y" && "$PREV_ROLE" == "node" && -n "$(prev_val CORE_HOST)" && -f "$NODE_KEY_FILE" ]]; then ask KJ "Оставить подключение к панели $(prev_val CORE_HOST):$(prev_val CORE_PORT) (нода #$(prev_val NODE_ID))? [Y/n]: " if [[ "${KJ:-y}" =~ ^[Yy]$ ]]; then CORE_HOST="$(prev_val CORE_HOST)"; CORE_PORT="$(prev_val CORE_PORT)" @@ -836,36 +933,39 @@ fi # Сводка. Метки выровнены пробелами прямо в строках: printf в bash считает ширину в байтах, # и кириллица поехала бы. echo -echo -e " ${BLD}Будет установлено${NC}" -rule -echo -e " ${DIM}Роль ${NC} ${BLD}$ROLE_LABEL${NC}" -echo -e " ${DIM}Адрес ${NC} $SERVER_IP ${DIM}· интерфейс $NET_IFACE${NC}" +card_line "$ACC" "${BLD}Будет установлено${NC}" +card_line "$ACC" +card_line "$ACC" "${DIM}Роль ${NC} ${BLD}$ROLE_LABEL${NC}" +card_line "$ACC" "${DIM}Адрес ${NC} $SERVER_IP ${DIM}· интерфейс $NET_IFACE${NC}" if [[ "$ROLE" != "core" ]]; then - echo -e " ${DIM}Имя сервера ${NC} $SERVER_NAME" - echo -e " ${DIM}VPN ${NC} ${AWG_PORT}/udp" + card_line "$ACC" "${DIM}Имя сервера ${NC} $SERVER_NAME" + card_line "$ACC" "${DIM}VPN ${NC} ${AWG_PORT}/udp" fi if [[ "$ROLE" != "node" ]]; then - echo -e " ${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER${NC}" - echo -e " ${DIM}Подписка sen://${NC} ${SUB_PORT}/tcp ${DIM}· $([[ "$SUB_TLS" == "on" ]] && echo "HTTPS" || echo "HTTP, ответы подписаны")${NC}" + card_line "$ACC" "${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER${NC}" + card_line "$ACC" "${DIM}Подписка sen://${NC} ${SUB_PORT}/tcp ${DIM}· $([[ "$SUB_TLS" == "on" ]] && echo "HTTPS" || echo "HTTP, ответы подписаны")${NC}" if [[ -n "$NODE_PORT" ]]; then - echo -e " ${DIM}Приём нод ${NC} ${NODE_PORT}/tcp" + card_line "$ACC" "${DIM}Приём нод ${NC} ${NODE_PORT}/tcp" else - echo -e " ${DIM}Приём нод ${NC} ${DIM}выключен${NC}" + card_line "$ACC" "${DIM}Приём нод ${NC} ${DIM}выключен${NC}" fi else - echo -e " ${DIM}Панель ${NC} $CORE_HOST:$CORE_PORT ${DIM}· нода #$NODE_ID${NC}" + card_line "$ACC" "${DIM}Панель ${NC} $CORE_HOST:$CORE_PORT ${DIM}· нода #$NODE_ID${NC}" fi if [[ "$KEEP_DATA" == "y" ]]; then if [[ "$ROLE" == "core" ]]; then - echo -e " ${DIM}Данные ${NC} ${GRN}сохраняются: серверы и ключи панели${NC}" + card_line "$ACC" "${DIM}Данные ${NC} ${GRN}сохраняются: серверы и ключи панели${NC}" else - echo -e " ${DIM}Данные ${NC} ${GRN}сохраняются: пользователи и ключ сервера${NC}" + card_line "$ACC" "${DIM}Данные ${NC} ${GRN}сохраняются: пользователи и ключ сервера${NC}" fi +elif [[ "$WIPE" == "y" ]]; then + card_line "$ACC" "${DIM}Данные ${NC} ${RED}все ключи и данные удаляются${NC} ${DIM}(устаревшая версия ${PREV_VERSION:-?}; базы — в бэкап)${NC}" elif [[ "$ROLE" != "core" && -f "$DB_FILE" ]] || [[ "$ROLE" == "core" && -f "$UI_DB_FILE" ]]; then - echo -e " ${DIM}Данные ${NC} ${YLW}с нуля, старая база уйдёт в бэкап${NC}" + card_line "$ACC" "${DIM}Данные ${NC} ${YLW}с нуля, старая база уйдёт в бэкап${NC}" fi -echo -e " ${DIM}Каталог ${NC} ${DIM}$PROJECT${NC}" -rule +card_line "$ACC" "${DIM}Каталог ${NC} ${DIM}$PROJECT${NC}" +card_line "$ACC" +echo ask YN "Продолжить? [Y/n]: " [[ "${YN:-y}" =~ ^[Nn]$ ]] && { echo " Отменено."; exit 0; } @@ -1110,6 +1210,13 @@ step "3/6 Ключи и конфиг AWG" mkdir -p "$AWG_DIR" "$AMNEZIA_DIR" +# Сброс устаревшей установки: ключ ноды — тоже в бэкап, агент создаст новый (ноду добавляют в +# панель заново). Остальное (базы, ключи сервера и подписи) сбрасывается как при KEEP_DATA=n ниже. +if [[ "$WIPE" == "y" && -f "$NODE_KEY_FILE" ]]; then + mv "$NODE_KEY_FILE" "${NODE_KEY_FILE}.bak-$(date +%Y%m%d-%H%M%S)" + warn "Старый ключ ноды убран в бэкап — ноду нужно заново добавить в панели" +fi + # ui.db (серверы, API-ключи, мастер-ключи awg-ui) — в бэкап, чтобы awg-ui создал чистую БД. if [[ "$KEEP_DATA" != "y" && -f "$UI_DB_FILE" ]]; then UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)" @@ -1175,9 +1282,10 @@ if [[ "$ROLE" != "node" ]]; then if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then ok "Сертификат подписки sen:// сохранён" else + # CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI. ( umask 077 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ - -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=sen" 2>/dev/null ) + -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=$(openssl rand -hex 6)" 2>/dev/null ) chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT" ok "Самоподписанный сертификат подписки sen:// создан (10 лет)" fi @@ -1515,29 +1623,32 @@ if [[ "$ROLE" == "node" ]]; then fi fi -rule -echo -e " ${GRN}${BLD}✓ Готово${NC} ${DIM}${BRAND} ${VERSION} · ${ROLE_LABEL}${NC}" -rule +echo +card_line "$GRN" +card_line "$GRN" "${GRN}${BLD}✓ Готово${NC} ${DIM}${BRAND} ${VERSION} · ${ROLE_LABEL}${NC}" +card_line "$GRN" if [[ "$ROLE" != "node" ]]; then - echo -e " ${DIM}Панель ${NC} ${BLD}http://${SERVER_IP}:${UI_PORT}${NC}" - echo -e " ${DIM}Логин ${NC} ${UI_USER}" - echo -e " ${DIM}Пароль ${NC} ${YLW}${UI_PASS}${NC} ${DIM}сменить: awg-ctrl credentials${NC}" - echo - echo -e " ${DIM}sen:// ${NC} $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} ${DIM}подписка SenAWG${NC}" - [[ -n "$NODE_PORT" ]] && echo -e " ${DIM}Ноды ${NC} wss://${SERVER_IP}:${NODE_PORT} ${DIM}«Добавить сервер» в панели${NC}" + card_line "$GRN" "${DIM}Панель ${NC} ${BLD}http://${SERVER_IP}:${UI_PORT}${NC}" + card_line "$GRN" "${DIM}Логин ${NC} ${UI_USER}" + card_line "$GRN" "${DIM}Пароль ${NC} ${YLW}${UI_PASS}${NC} ${DIM}сменить: awg-ctrl credentials${NC}" + card_line "$GRN" + card_line "$GRN" "${DIM}sen:// ${NC} $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} ${DIM}подписка SenAWG${NC}" + [[ -n "$NODE_PORT" ]] && card_line "$GRN" "${DIM}Ноды ${NC} wss://${SERVER_IP}:${NODE_PORT} ${DIM}«Добавить сервер» в панели${NC}" fi if [[ "$ROLE" != "core" ]]; then - echo -e " ${DIM}VPN ${NC} ${SERVER_IP}:${AWG_PORT}/udp ${DIM}· awg-ctrl на 127.0.0.1:${AWGCTRL_PORT}${NC}" + card_line "$GRN" "${DIM}VPN ${NC} ${SERVER_IP}:${AWG_PORT}/udp ${DIM}· awg-ctrl на 127.0.0.1:${AWGCTRL_PORT}${NC}" fi if [[ "$ROLE" == "node" ]]; then - echo -e " ${DIM}Панель ${NC} wss://${CORE_HOST}:${CORE_PORT} ${DIM}исходящее подключение${NC}" - echo - echo -e " Сервер «${SERVER_NAME}» появится в списке серверов панели — управляй им оттуда." + card_line "$GRN" "${DIM}Панель ${NC} wss://${CORE_HOST}:${CORE_PORT} ${DIM}исходящее подключение${NC}" + card_line "$GRN" + card_line "$GRN" "Сервер «${SERVER_NAME}» появится в списке серверов панели — управляй им оттуда." fi -rule -echo -e " ${YLW}⚠${NC} Если у хостера свой фаервол (панель провайдера, security group) — открой там:" -echo -e " ${BLD}${OPEN_PORTS[*]}${NC} ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}" -rule +card_line "$GRN" +echo +card_line "$YLW" "${YLW}⚠${NC} Если у хостера свой фаервол (панель провайдера, security group) — открой там:" +card_line "$YLW" " ${BLD}${OPEN_PORTS[*]}${NC}" +card_line "$YLW" " ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}" +echo echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, join/unjoin" echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}" echo diff --git a/internal/agent/agent.go b/internal/agent/agent.go index afc679e..804aad8 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -511,6 +511,9 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo if code == closeAuthFailed { a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)") } + if code == 4026 { + a.c.Errorf("agent: панель требует обновить ноду (install.sh → «Обновить»): %v", err) + } return code == closeAuthFailed } v, err := jsjson.Decode(raw) diff --git a/internal/ctrl/awg.go b/internal/ctrl/awg.go index 6d80be2..5752bce 100644 --- a/internal/ctrl/awg.go +++ b/internal/ctrl/awg.go @@ -344,6 +344,7 @@ func (s *Server) syncPeers() error { // vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1. func (s *Server) reloadAwgParams() string { p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) }) + p.I1 = s.getCfg(cfgI1, p.I1) s.cfgMu.Lock() s.c.Params = p s.c.Gen = p.Gen() diff --git a/internal/ctrl/i1.go b/internal/ctrl/i1.go new file mode 100644 index 0000000..a178da0 --- /dev/null +++ b/internal/ctrl/i1.go @@ -0,0 +1,219 @@ +package ctrl + +import ( + "encoding/binary" + "encoding/hex" + "math/rand/v2" + "regexp" + "strconv" + "strings" +) + +// I1 — «сигнатурный» пакет, который клиент шлёт перед рукопожатием, чтобы начало сессии +// выглядело как другой протокол. Сервер I-пакеты отбрасывает (они не совпадают ни с одним +// H-заголовком), поэтому совпадать у сервера и клиента I1 не обязан: выданные vpn:// ключи со +// старым I1 продолжают подключаться, а смена касается только новых конфигов. +// +// Поэтому I1 живёт не в awg1.conf, а в БД awg-ctrl (config.i1): в ядро он не уходит — нет +// риска, что модуль не примет шаблон и интерфейс не поднимется, — и переживает KEEP_DATA вместе +// с базой. Если I1 вписан в awg1.conf руками, на первом старте он и берётся. +// +// Шаблон — один из нескольких правдоподобных (DNS-запрос, начало QUIC Initial, STUN binding +// request) со случайными полями: у каждого сервера свой, общий для всех I1 был бы сигнатурой. +// Только теги и : их понимают все клиенты с I1–I5. Идентификаторы транзакций и +// соединений — , они случайны в каждом пакете, как у настоящих протоколов. +const cfgI1 = "i1" + +// Популярные домены, запросы к которым в любой сети выглядят обычно. Заблокированных в РФ +// нет: запрос к ним сам по себе привлекал бы внимание. +var i1Domains = []string{ + "google.com", "www.google.com", "youtube.com", "gstatic.com", "googleapis.com", + "apple.com", "icloud.com", "microsoft.com", "bing.com", "live.com", "cloudflare.com", + "amazon.com", "wikipedia.org", "github.com", "yandex.ru", "ya.ru", "vk.com", "mail.ru", + "ok.ru", "avito.ru", "ozon.ru", "wildberries.ru", "gosuslugi.ru", "dzen.ru", "2gis.ru", +} + +// GenI1 — случайный I1 из одного из шаблонов. +func GenI1() string { + switch rand.IntN(3) { + case 0: + return i1DNS() + case 1: + return i1QUIC() + default: + return i1STUN() + } +} + +func pick[T any](v ...T) T { return v[rand.IntN(len(v))] } + +// DNS-запрос: ID — , флаги RD, один вопрос A/AAAA/HTTPS, иногда EDNS0 (как у systemd-resolved, +// браузеров и роутеров). +func i1DNS() string { + var b []byte + edns := rand.IntN(2) == 0 + ar := uint16(0) + if edns { + ar = 1 + } + b = binary.BigEndian.AppendUint16(b, 0x0100) // флаги: стандартный запрос, RD + b = append(b, 0, 1, 0, 0, 0, 0) // QDCOUNT 1, ANCOUNT 0, NSCOUNT 0 + b = binary.BigEndian.AppendUint16(b, ar) // ARCOUNT + for _, l := range strings.Split(pick(i1Domains...), ".") { // QNAME + b = append(b, byte(len(l))) + b = append(b, l...) + } + b = append(b, 0) + b = binary.BigEndian.AppendUint16(b, pick[uint16](1, 1, 28, 65)) // QTYPE: A чаще, AAAA, HTTPS + b = append(b, 0, 1) // QCLASS IN + if edns { + b = append(b, 0, 0, 41) // OPT, имя — корень + b = binary.BigEndian.AppendUint16(b, pick[uint16](1232, 4096, 1400)) + b = append(b, 0, 0) // расширенный RCODE, версия + b = binary.BigEndian.AppendUint16(b, pick[uint16](0, 0x8000)) // флаг DO + b = append(b, 0, 0) // RDLEN + } + return "" +} + +// Начало QUIC v1 Initial: длинный заголовок (младшие биты первого байта под header protection — +// случайны), DCID 8 байт, SCID 0 или 8, без токена; остаток до 1200/1250 байт — шифрованная +// полезная нагрузка, её изображает . +func i1QUIC() string { + scid := pick(0, 8) + total := pick(1200, 1250, 1252) + head := 1 + 4 + 1 + 8 + 1 + scid + 1 + 2 + rest := total - head + s := "" + if scid > 0 { + s += "" + } else { + s += "" + } + return s + "" +} + +// STUN binding request (RFC 5389): magic cookie, ID транзакции — ; иногда с +// CHANGE-REQUEST (проверка NAT, RFC 5780). +func i1STUN() string { + attrs := pick([]byte{}, []byte{0, 3, 0, 4, 0, 0, 0, 0}) + b := []byte{0, 1} + b = binary.BigEndian.AppendUint16(b, uint16(len(attrs))) + b = append(b, 0x21, 0x12, 0xa4, 0x42) + s := "" + if len(attrs) > 0 { + s += "" + } + return s +} + +var reI1Tag = regexp.MustCompile(`<(b|r) ([^>]*)>`) + +// i1Bytes раскрывает шаблон в байты ( — нули); ok=false для тегов, кроме /. +func i1Bytes(s string) ([]byte, bool) { + var out []byte + rest := strings.TrimSpace(s) + for rest != "" { + m := reI1Tag.FindStringSubmatchIndex(rest) + if m == nil || m[0] != 0 { + return nil, false + } + kind, val := rest[m[2]:m[3]], rest[m[4]:m[5]] + if kind == "b" { + h, err := hex.DecodeString(strings.TrimPrefix(val, "0x")) + if err != nil { + return nil, false + } + out = append(out, h...) + } else { + n, err := strconv.Atoi(val) + if err != nil || n < 0 || n > 65535 { + return nil, false + } + out = append(out, make([]byte, n)...) + } + rest = rest[m[1]:] + } + return out, len(out) > 0 +} + +// dnsName — имя первого вопроса DNS-пакета. +func dnsName(b []byte) string { + var parts []string + for i := 12; i < len(b) && b[i] != 0; { + n := int(b[i]) + if n > 63 || i+1+n > len(b) { + return "" + } + parts = append(parts, string(b[i+1:i+1+n])) + i += 1 + n + } + return strings.Join(parts, ".") +} + +// DescribeI1 — под что маскируется I1, для панели: вид (dns, quic, stun, custom, none) и подпись. +func DescribeI1(s string) (kind, label string) { + if strings.TrimSpace(s) == "" { + return "none", "без маскировки" + } + b, ok := i1Bytes(s) + switch { + case !ok: + case len(b) >= 20 && binary.BigEndian.Uint32(b[4:8]) == 0x2112a442 && b[0] == 0 && b[1] == 1: + return "stun", "STUN binding request" + case len(b) >= 6 && b[0]&0xc0 == 0xc0 && binary.BigEndian.Uint32(b[1:5]) == 1: + return "quic", "QUIC Initial, " + strconv.Itoa(len(b)) + " байт" + case len(b) > 12 && b[2]&0x80 == 0 && binary.BigEndian.Uint16(b[4:6]) == 1: + if n := dnsName(b); n != "" { + return "dns", "DNS-запрос к " + n + } + case len(b) > 12 && b[2]&0x80 != 0: + if n := dnsName(b); n != "" { + return "dns", "DNS-ответ от " + n + } + } + return "custom", "свой шаблон" +} + +// i1FromConf — I1, вписанный в [Interface] awg1.conf руками (ReadParams без него отдаёт +// значение по умолчанию). +func i1FromConf(confFile string) string { + p := ReadParams(confFile, nil) + if p.I1 == DefaultParams().I1 { + return "" + } + return p.I1 +} + +// ensureI1 — на старте: I1 этого сервера из БД; нет — из awg1.conf, нет и там — новый случайный. +func (s *Server) ensureI1() string { + if v := s.getCfg(cfgI1, ""); v != "" { + return v + } + v := i1FromConf(s.confPath()) + if v == "" { + v = GenI1() + } + s.setCfg(cfgI1, v) + kind, label := DescribeI1(v) + s.log.Info("I1 assigned", "kind", kind, "label", label) + return v +} + +// regenI1 — новый случайный I1. Клиенты по sen:// получают его со следующим обновлением +// профиля; vpn:// ключи несут прежний и продолжают работать, пока их не перевыпустят. +func (s *Server) regenI1() string { + v := GenI1() + s.setCfg(cfgI1, v) + s.cfgMu.Lock() + s.c.Params.I1 = v + s.cfgMu.Unlock() + kind, label := DescribeI1(v) + s.log.Info("I1 regenerated", "kind", kind, "label", label) + return v +} + +func maskingInfo(i1 string) ob { + kind, label := DescribeI1(i1) + return ob{{"i1", i1}, {"kind", kind}, {"label", label}} +} diff --git a/internal/ctrl/i1_test.go b/internal/ctrl/i1_test.go new file mode 100644 index 0000000..a127ee3 --- /dev/null +++ b/internal/ctrl/i1_test.go @@ -0,0 +1,58 @@ +package ctrl + +import ( + "strings" + "testing" +) + +func TestGenI1Templates(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 500; i++ { + v := GenI1() + b, ok := i1Bytes(v) + if !ok { + t.Fatalf("шаблон не разбирается: %q", v) + } + kind, label := DescribeI1(v) + seen[kind] = true + switch kind { + case "dns": + if !strings.HasPrefix(v, "") || !strings.HasPrefix(label, "DNS-запрос к ") { + t.Fatalf("DNS: %q → %q", v, label) + } + case "quic": + if n := len(b); n != 1200 && n != 1250 && n != 1252 { + t.Fatalf("QUIC длиной %d: %q", n, v) + } + case "stun": + if len(b) != 20 && len(b) != 28 { + t.Fatalf("STUN длиной %d: %q", len(b), v) + } + default: + t.Fatalf("неожиданный вид %q: %q", kind, v) + } + // Только /, без пробелов вне тегов — так I1 пишут и клиенты AmneziaVPN. + if strings.Contains(strings.NewReplacer("": "свой шаблон", + "": "свой шаблон", + } + for in, want := range cases { + if _, got := DescribeI1(in); got != want { + t.Errorf("DescribeI1(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/ctrl/routes.go b/internal/ctrl/routes.go index 5370b0c..ffd56f2 100644 --- a/internal/ctrl/routes.go +++ b/internal/ctrl/routes.go @@ -300,6 +300,12 @@ func (s *Server) routes() []route { {"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}}) }), + // Маскировка первого пакета (I1): посмотреть и сменить. Путь под /api/ — его пропускает + // awg-agent нод. Интерфейс не трогается: I1 нужен только клиентам (см. i1.go). + R("GET", "/api/masking", false, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.cfg().Params.I1)) }), + + R("POST", "/api/masking", true, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.regenI1())) }), + R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) { if err := s.restartAwg(); err != nil { return result{}, err diff --git a/internal/ctrl/server.go b/internal/ctrl/server.go index cafd40a..e68c0fa 100644 --- a/internal/ctrl/server.go +++ b/internal/ctrl/server.go @@ -146,6 +146,7 @@ func New() (*Server, error) { sp, _ := strconv.Atoi(portStr) params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) }) + params.I1 = s.ensureI1() // I1 — из БД, не из conf (см. i1.go) gen := params.Gen() module, tools := readVersions() s.c = Config{ diff --git a/internal/ui/monitor_test.go b/internal/ui/monitor_test.go index f02986c..64fcc36 100644 --- a/internal/ui/monitor_test.go +++ b/internal/ui/monitor_test.go @@ -91,7 +91,7 @@ func TestPrometheusOnNodeZero(t *testing.T) { ag, err := agent.New(agent.Config{ CoreHost: "127.0.0.1", CorePort: hubPort, CorePin: b64url.EncodeToString(a.nodes.pin), NodeID: 0, Ctrl: ctrl.URL, NodeKeyFile: keyFile, InternalKey: filepath.Join(dir, "none"), - PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile, + PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile, Version: MinNodeVersion, Logf: t.Logf, Errorf: t.Logf, }) if err != nil { diff --git a/internal/ui/nodes.go b/internal/ui/nodes.go index 50f3d28..037ebdd 100644 --- a/internal/ui/nodes.go +++ b/internal/ui/nodes.go @@ -159,8 +159,11 @@ func newNodes(a *App) (*Nodes, error) { keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt") if err := func() error { if !exists(keyF) && !exists(crtF) { + // CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI. + cn := make([]byte, 6) + rand.Read(cn) cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", - "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node") + "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", fmt.Sprintf("/CN=%x", cn)) if err := cmd.Run(); err != nil { return err } @@ -473,6 +476,30 @@ func (n *Nodes) dropConn(id int64, c *nodeConn) { n.mu.Unlock() } +// MinNodeVersion — самая старая версия ноды, с которой работает панель (в UI — MIN_NODE_VERSION). +const MinNodeVersion = "0.5.0" + +// closeOutdated — код закрытия для ноды старше MinNodeVersion. +const closeOutdated = 4026 + +// cmpVersion сравнивает версии вида 0.5.0 (хвост после «-» не учитывается): <0 — a старше b. +func cmpVersion(a, b string) int { + pa, pb := strings.Split(strings.SplitN(a, "-", 2)[0], "."), strings.Split(strings.SplitN(b, "-", 2)[0], ".") + for i := 0; i < max(len(pa), len(pb)); i++ { + var x, y int + if i < len(pa) { + x, _ = strconv.Atoi(pa[i]) + } + if i < len(pb) { + y, _ = strconv.Atoi(pb[i]) + } + if x != y { + return x - y + } + } + return 0 +} + func closeWS(ws *websocket.Conn, code int, reason string) { go ws.Close(websocket.StatusCode(code), reason) } @@ -540,6 +567,13 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { version = v } n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID) + // Нода старше MinNodeVersion (или без версии — агент старше 0.3.4) с этой панелью не работает: + // соединение не принимаем, для панели она «нет связи», пока её не обновят (агент переподключится сам). + if v, _ := version.(string); v == "" || cmpVersion(v, MinNodeVersion) < 0 { + logf("nodes: «%s» (#%d) — версия %q, нужна не ниже %s: соединение отклонено", row.Name, row.ID, v, MinNodeVersion) + closeWS(ws, closeOutdated, "outdated "+MinNodeVersion) + return + } conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}} nodeID = row.ID @@ -775,7 +809,8 @@ func (n *Nodes) List() []serverInfo { } for _, r := range n.all() { out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid, - LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID]}) + LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID], + Outdated: r.Pub.Valid && (!r.Version.Valid || cmpVersion(r.Version.String, MinNodeVersion) < 0)}) } return out } @@ -789,11 +824,12 @@ type serverInfo struct { LastSeen any Version any Health *health + Outdated bool // старше MinNodeVersion: хаб её не подключает, пока не обновят } func (s serverInfo) json() jsjson.Object { return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online}, - {Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}} + {Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}, {Key: "outdated", Val: s.Outdated}} } func (n *Nodes) idOf(c *Ctx) (int64, bool) { diff --git a/internal/ui/outdated_test.go b/internal/ui/outdated_test.go new file mode 100644 index 0000000..db5a030 --- /dev/null +++ b/internal/ui/outdated_test.go @@ -0,0 +1,88 @@ +package ui + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +// Нода старше MinNodeVersion (или без версии) с панелью не работает: хаб отклоняет её с 4026 и +// не считает подключённой, но версию запоминает — панель показывает, до чего обновлять. +func TestOutdatedNodeRejected(t *testing.T) { + p := newMovePanel(t, "off", "40500") + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', ?, 1)", b64url.EncodeToString(pub)); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS)) + defer srv.Close() + + connect := func(version string) (jsjson.Object, websocket.StatusCode) { + ctx, cancel := context.WithTimeout(context.Background(), 5*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, error) { + _, raw, err := ws.Read(ctx) + if err != nil { + return nil, err + } + v, _ := jsjson.Decode(raw) + o, _ := v.(jsjson.Object) + return o, nil + } + hello, _ := read() + auth := jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3}, + {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(hello.Get2("nonce").(string), 3)))}} + if version != "" { + auth = append(auth, jsjson.KV{Key: "version", Val: version}) + } + ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(auth)) + o, err := read() + return o, websocket.CloseStatus(err) + } + + for _, v := range []string{"0.4.9", ""} { + if o, code := connect(v); code != closeOutdated { + t.Fatalf("версия %q: ждали закрытия %d, получили %v / %v", v, closeOutdated, o, code) + } + p.a.nodes.mu.Lock() + _, on := p.a.nodes.conns[3] + p.a.nodes.mu.Unlock() + if on { + t.Fatalf("версия %q: нода считается подключённой", v) + } + } + var stored string + p.a.db.QueryRow("SELECT COALESCE(version, '') FROM nodes WHERE id = 3").Scan(&stored) + if stored != "" { // последняя попытка — без версии + t.Fatalf("версия в БД: %q", stored) + } + if o, code := connect(MinNodeVersion); o.Get2("t") != "ready" { + t.Fatalf("актуальная версия: %v / %v", o, code) + } +} + +func TestCmpVersion(t *testing.T) { + for _, c := range []struct { + a, b string + sign int + }{{"0.5.0", "0.5.0", 0}, {"0.4.9", "0.5.0", -1}, {"0.10.0", "0.9.9", 1}, {"0.5", "0.5.0", 0}, {"0.5.1-beta", "0.5.1", 0}} { + got := cmpVersion(c.a, c.b) + if (got > 0) != (c.sign > 0) || (got < 0) != (c.sign < 0) { + t.Errorf("cmpVersion(%q, %q) = %d", c.a, c.b, got) + } + } +} diff --git a/internal/ui/retire_test.go b/internal/ui/retire_test.go index ffd716c..4a6c2d0 100644 --- a/internal/ui/retire_test.go +++ b/internal/ui/retire_test.go @@ -61,7 +61,7 @@ func TestRetireReadiness(t *testing.T) { 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)))}}) + {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}, {Key: "version", Val: MinNodeVersion}}) if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` { t.Fatalf("ready: %s", got) }