diff --git a/awg-ctrl/index.ts b/awg-ctrl/index.ts index 71f79d3..18ef016 100644 --- a/awg-ctrl/index.ts +++ b/awg-ctrl/index.ts @@ -64,6 +64,21 @@ db.exec(` db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)"); +// Миграции users: better-sqlite3 синхронный, ALTER TABLE идемпотентным не бывает, +// поэтому смотрим фактический список колонок. +// key_gen — поколение протокола, на параметрах которого выдан vpn_key +// ('2' для всего, что заведено до появления 3.1) +// vpn_key_prev — предыдущий блоб, чтобы перевыпуск можно было откатить +{ + const cols = new Set( + (db.prepare("PRAGMA table_info(users)").all() as { name: string }[]).map(c => c.name), + ); + if (!cols.has("key_gen")) + db.exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'"); + if (!cols.has("vpn_key_prev")) + db.exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''"); +} + db.exec(` CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, @@ -106,12 +121,29 @@ function initConfig() { return { serverIp, serverPort: Number(serverPort), serverName }; } -interface AwgParams { +// Ключи, появившиеся в AmneziaWG 3.0/3.1. Пустая строка = ключ не задан, тогда +// интерфейс работает в режиме 2.0 и его нет ни в conf, ни в vpn:// ключе. +// Порядок массива = порядок строк в клиентском .conf (см. buildClientConf). +const AWG3_KEYS = [ + "HeaderProtectionKey", + "ContentPaddingAddition", + "RekeyAfterTime", + "RekeyTimeout", + "RejectAfterTime", + "KeepaliveTimeout", + "MaxHandshakeAttempts", + "RandomTrailers", + "DisableCookies", +] as const; + +type Awg3Key = (typeof AWG3_KEYS)[number]; + +type AwgParams = { Jc: number; Jmin: number; Jmax: number; S1: number; S2: number; S3: number; S4: number; H1: string; H2: string; H3: string; H4: string; I1: string; I2: string; I3: string; I4: string; I5: string; -} +} & Record; const DEFAULT_AWG_PARAMS: AwgParams = { Jc: 6, Jmin: 10, Jmax: 50, @@ -122,6 +154,11 @@ const DEFAULT_AWG_PARAMS: AwgParams = { H4: "2143656228-2147444225", I1: "", I2: "", I3: "", I4: "", I5: "", + // 3.x по умолчанию выключен: без conf-а сервер остаётся на 2.0. + HeaderProtectionKey: "", ContentPaddingAddition: "", + RekeyAfterTime: "", RekeyTimeout: "", RejectAfterTime: "", + KeepaliveTimeout: "", MaxHandshakeAttempts: "", + RandomTrailers: "", DisableCookies: "", }; function readAwgParams(): AwgParams { @@ -134,7 +171,9 @@ function readAwgParams(): AwgParams { const iface = readFileSync(confFile, "utf8").split(/^\[Peer\]/m)[0]; const numKeys: (keyof AwgParams)[] = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"]; - const strKeys: (keyof AwgParams)[] = ["H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]; + const strKeys: (keyof AwgParams)[] = [ + "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5", ...AWG3_KEYS, + ]; for (const k of numKeys) { const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m")); @@ -144,11 +183,26 @@ function readAwgParams(): AwgParams { const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m")); if (m) (params[k] as string) = m[1].trim(); } - logger.info("awg params loaded from conf", { Jc: params.Jc, H1: params.H1 }); + logger.info("awg params loaded from conf", { + Jc: params.Jc, H1: params.H1, gen: genOf(params), + }); return params; } +// Поколение протокола выводим из самих параметров, отдельного флага нет: conf +// остаётся единственным источником правды. Header protection — та фича, которая +// ломает совместимость с 2.0, поэтому именно она и определяет поколение. +function genOf(p: AwgParams): "2" | "3.1" { + return p.HeaderProtectionKey ? "3.1" : "2"; +} + +// PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN); +// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг. +const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" }; + const runtimeConfig = initConfig(); +const AWG_PARAMS = readAwgParams(); +const AWG_GEN = genOf(AWG_PARAMS); const CONFIG = { interface: "awg1", confDir: "/etc/amnezia/amneziawg", @@ -159,23 +213,32 @@ const CONFIG = { dns1: "1.1.1.1", dns2: "1.0.0.1", mtu: 1376, - keepalive: 25, - awgParams: readAwgParams(), + keepalive: KEEPALIVE_BY_GEN[AWG_GEN], + awgParams: AWG_PARAMS, + gen: AWG_GEN, }; interface UserRow { - name: string; - ip: string; - pub_key: string; - vpn_key: string; - psk_key: string; + name: string; + ip: string; + pub_key: string; + vpn_key: string; + psk_key: string; + key_gen: string; + vpn_key_prev: string; } const stmts = { get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"), - insert: db.prepare<[string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key) VALUES (?, ?, ?, ?, ?)"), + all: db.prepare<[], UserRow>("SELECT * FROM users"), + insert: db.prepare<[string, string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)"), delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"), ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"), + // Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут + // смениться, если исходный ключ не удалось разобрать. + reissue: db.prepare<[string, string, string, string, string]>( + "UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?", + ), }; function run(cmd: string): string { @@ -207,7 +270,10 @@ function nextIp(): string { throw new Error("Подсеть заполнена"); } -// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → Address → DNS +// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → 3.x-ключи → +// Address → DNS. Порядок 3.x-блока взят из client/server_scripts/awg/template.conf +// клиента AmneziaVPN; пустые ключи не выводятся вовсе — тогда конфиг остаётся +// ровно тем же 2.0-конфигом, что и до появления поддержки 3.1. // ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом // (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали // ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»! @@ -217,6 +283,7 @@ function buildClientConf( serverPub: string, ): string { const p = CONFIG.awgParams; + const awg3 = AWG3_KEYS.filter(k => p[k]).map(k => `${k} = ${p[k]}\n`).join(""); return `[Interface] PrivateKey = ${keys.privateKey} Jc = ${p.Jc} @@ -235,7 +302,7 @@ I2 =${" "} I3 =${" "} I4 =${" "} I5 =${" "} -Address = ${ip}/32 +${awg3}Address = ${ip}/32 DNS = ${CONFIG.dns1}, ${CONFIG.dns2} [Peer] @@ -255,12 +322,29 @@ function encodeVpnKey( const p = CONFIG.awgParams; const clientConf = buildClientConf(keys, ip, serverPub); - const lastConfigObj = { + // Ключи в объектах идут в том же ASCII-алфавитном порядке, в каком их + // сериализует QJsonObject клиента AmneziaVPN. Незаданные 3.x-ключи + // выбрасываются в dropEmptyAwg3 — на 2.0 объекты остаются прежними байт-в-байт. + const dropEmptyAwg3 = >(o: T): T => { + for (const k of AWG3_KEYS) if (!p[k]) delete o[k]; + return o; + }; + + const lastConfigObj = dropEmptyAwg3({ + ContentPaddingAddition: p.ContentPaddingAddition, + DisableCookies: p.DisableCookies, H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4, + HeaderProtectionKey: p.HeaderProtectionKey, I1: p.I1, I2: "", I3: "", I4: "", I5: "", Jc: String(p.Jc), Jmax: String(p.Jmax), Jmin: String(p.Jmin), + KeepaliveTimeout: p.KeepaliveTimeout, + MaxHandshakeAttempts: p.MaxHandshakeAttempts, + RandomTrailers: p.RandomTrailers, + RejectAfterTime: p.RejectAfterTime, + RekeyAfterTime: p.RekeyAfterTime, + RekeyTimeout: p.RekeyTimeout, S1: String(p.S1), S2: String(p.S2), S3: String(p.S3), S4: String(p.S4), allowed_ips: ["0.0.0.0/0", "::/0"], clientId: keys.publicKey, @@ -274,25 +358,34 @@ function encodeVpnKey( port: CONFIG.serverPort, psk_key: keys.presharedKey, server_pub_key: serverPub, - }; + }); const json = JSON.stringify({ containers: [{ container: "amnezia-awg2", - awg: { + awg: dropEmptyAwg3({ + ContentPaddingAddition: p.ContentPaddingAddition, + DisableCookies: p.DisableCookies, H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4, + HeaderProtectionKey: p.HeaderProtectionKey, I1: p.I1, I2: "", I3: "", I4: "", I5: "", Jc: String(p.Jc), Jmax: String(p.Jmax), Jmin: String(p.Jmin), + KeepaliveTimeout: p.KeepaliveTimeout, + MaxHandshakeAttempts: p.MaxHandshakeAttempts, + RandomTrailers: p.RandomTrailers, + RejectAfterTime: p.RejectAfterTime, + RekeyAfterTime: p.RekeyAfterTime, + RekeyTimeout: p.RekeyTimeout, S1: String(p.S1), S2: String(p.S2), S3: String(p.S3), S4: String(p.S4), last_config: JSON.stringify(lastConfigObj, null, 2), port: String(CONFIG.serverPort), - protocol_version: "2", + protocol_version: CONFIG.gen, subnet_address: `${CONFIG.subnet}.0.0`, transport_proto: "udp", - }, + }), }], defaultContainer: "amnezia-awg2", description: CONFIG.serverName, @@ -311,6 +404,77 @@ function encodeVpnKey( .replace(/=+$/, ""); } +// Обратная к encodeVpnKey: vpn:// → base64url → снять 4-байтовый BE-заголовок +// длины → inflate → JSON. Приватный ключ клиента больше нигде не хранится, поэтому +// это единственный способ перевыпустить ключ, не меняя личность пира. +function decodeVpnKey(vpnKey: string): any | null { + try { + const buf = Buffer.from(vpnKey.replace(/^vpn:\/\//, ""), "base64url"); + if (buf.length <= 4) return null; + return JSON.parse(zlib.inflateSync(buf.subarray(4)).toString("utf8")); + } catch { + return null; + } +} + +function clientPrivKeyFrom(vpnKey: string): string | null { + const lastConfig = decodeVpnKey(vpnKey)?.containers?.[0]?.awg?.last_config; + if (typeof lastConfig !== "string") return null; + try { + const priv = JSON.parse(lastConfig).client_priv_key; + return typeof priv === "string" && priv ? priv : null; + } catch { + return null; + } +} + +interface ReissueResult { total: number; reissued: number; regenerated: string[]; backup: string } + +// Перевыпуск всех vpn:// ключей на текущих параметрах интерфейса. Нужен после +// смены поколения (2.0 → 3.1): старые ключи собраны на старых параметрах и +// перестают работать. IP, pub_key и psk_key сохраняются — на проводе ничего не +// меняется, клиенту достаточно заново импортировать ключ. +function reissueAll(): ReissueResult { + const users = stmts.all.all(); + const serverPub = getServerPublicKey(); + const backup = `${dbPath}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`; + db.prepare("VACUUM INTO ?").run(backup); + logger.info("reissue: db backed up", { backup, users: users.length }); + + const regenerated: string[] = []; + let reissued = 0; + + for (const u of users) { + const priv = clientPrivKeyFrom(u.vpn_key); + let keys: ReturnType; + + if (priv) { + keys = { privateKey: priv, publicKey: u.pub_key, presharedKey: u.psk_key }; + } else { + // Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую. + logger.warn("reissue: vpn_key не декодируется, генерируем новую пару", { name: u.name }); + keys = generateKeys(); + spawnSync("awg", ["set", CONFIG.interface, "peer", u.pub_key, "remove"]); + const r = setPeer(keys.publicKey, keys.presharedKey, u.ip); + if (r.status !== 0) { + logger.error("reissue: awg set failed", { name: u.name, stderr: r.stderr?.toString() }); + continue; + } + regenerated.push(u.name); + } + + stmts.reissue.run( + encodeVpnKey(keys, u.ip, serverPub), + keys.publicKey, keys.presharedKey, CONFIG.gen, u.name, + ); + reissued++; + } + + syncPeers(); + logger.info("reissue: done", { total: users.length, reissued, regenerated: regenerated.length }); + return { total: users.length, reissued, regenerated, backup }; +} + function getPeersData(): Record { try { const output = run(`awg show ${CONFIG.interface} dump`); @@ -430,36 +594,47 @@ function startInterface() { return getInterfaceStatus(); } +// PSK уходит во временный файл, а не в аргументы: иначе он виден в /proc//cmdline. +function setPeer(pubKey: string, psk: string, ip: string) { + const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`; + writeFileSync(tmpPsk, psk, { mode: 0o600 }); + try { + return spawnSync("awg", [ + "set", CONFIG.interface, "peer", pubKey, + "preshared-key", tmpPsk, + "allowed-ips", `${ip}/32`, + "persistent-keepalive", CONFIG.keepalive, + ]); + } finally { + try { fs.unlinkSync(tmpPsk); } catch {} + } +} + function addUser(username: string): UserRow { const keys = generateKeys(); const serverPub = getServerPublicKey(); const ip = db.transaction(() => { const ip = nextIp(); - stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey); + stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey, CONFIG.gen); return ip; })(); const vpn_key = encodeVpnKey(keys, ip, serverPub); db.prepare("UPDATE users SET vpn_key = ? WHERE name = ?").run(vpn_key, username); - const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`; - writeFileSync(tmpPsk, keys.presharedKey); - const r = spawnSync("awg", [ - "set", CONFIG.interface, "peer", keys.publicKey, - "preshared-key", tmpPsk, - "allowed-ips", `${ip}/32`, - "persistent-keepalive", String(CONFIG.keepalive), - ]); - try { fs.unlinkSync(tmpPsk); } catch {} + const r = setPeer(keys.publicKey, keys.presharedKey, ip); if (r.status !== 0) { stmts.delete.run(username); throw new Error(`awg set failed: ${r.stderr?.toString()}`); } rebuildConf(); - logger.info("user created", { name: username, ip }); - return { name: username, ip, pub_key: keys.publicKey, vpn_key, psk_key: keys.presharedKey }; + logger.info("user created", { name: username, ip, gen: CONFIG.gen }); + return { + name: username, ip, pub_key: keys.publicKey, vpn_key, + psk_key: keys.presharedKey, key_gen: CONFIG.gen, vpn_key_prev: "", + }; } function removeUser(username: string) { @@ -521,6 +696,7 @@ app.get("/health", (_req, res) => { status: up ? "ok" : "degraded", server: CONFIG.serverName, ip: CONFIG.serverIp, + gen: CONFIG.gen, awg: { status: up ? "ok" : "down", peers }, }); }); @@ -534,7 +710,7 @@ app.post("/api/users", auth, validateName, handler((req, res) => { })); app.get("/api/users", auth, handler((_req, res) => { - const users = db.prepare("SELECT name, ip, pub_key, vpn_key FROM users WHERE vpn_key != ''").all() as UserRow[]; + const users = db.prepare("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[]; const peers = getPeersData(); res.json({ users: users.map(u => ({ @@ -546,12 +722,13 @@ app.get("/api/users", auth, handler((_req, res) => { })); app.get("/api/users/stats", auth, handler((_req, res) => { - const users = db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[]; + const users = db.prepare("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[]; const peers = getPeersData(); res.json({ users: users.map(u => ({ name: u.name, ip: u.ip, + key_gen: u.key_gen, online: peers[u.pub_key]?.online ?? false, lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0, rx: peers[u.pub_key]?.rx ?? 0, @@ -560,6 +737,12 @@ app.get("/api/users/stats", auth, handler((_req, res) => { }); })); +// ⚠️ Должен быть объявлен ДО «/api/users/:name», иначе тот перехватит «reissue» +// как имя пользователя. +app.post("/api/users/reissue", auth, handler((_req, res) => { + res.json(reissueAll()); +})); + app.post("/api/users/:name", auth, validateName, handler((req, res) => { const user = stmts.get.get(req.params.name); if (!user) { res.status(404).json({ error: "Пользователь не найден" }); return; } diff --git a/awg-ui/server.ts b/awg-ui/server.ts index a8ad4ee..594176b 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -210,6 +210,10 @@ async function ctrl(method: string, urlPath: string, body?: unknown) { }); } +// Внешний контракт: наружу отдаём только name/ip/gen/vpn_key — psk_key и pub_key +// остаются внутри. gen — поколение AmneziaWG, на параметрах которого выдан ключ. +interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string } + const ext = express.Router(); ext.use(requireApiKey); @@ -217,8 +221,8 @@ ext.post("/users", async (req: Request, res: Response) => { const { name } = (req.body ?? {}) as { name?: string }; const r = await ctrl("POST", "/api/users", { name }); if (r.status >= 400) { res.status(r.status).json(r.data); return; } - const u = r.data as { name: string; ip: string; vpn_key: string }; - res.status(201).json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key }); + const u = r.data as ExtUser; + res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); }); ext.get("/users", async (_req: Request, res: Response) => { @@ -229,8 +233,8 @@ ext.get("/users", async (_req: Request, res: Response) => { ext.get("/users/:name", async (req: Request, res: Response) => { const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`); if (r.status >= 400) { res.status(r.status).json(r.data); return; } - const u = r.data as { name: string; ip: string; vpn_key: string }; - res.json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key }); + const u = r.data as ExtUser; + res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); }); ext.delete("/users/:name", async (req: Request, res: Response) => { diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index 443b04e..c6d1a72 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -352,6 +352,8 @@ 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 .chip-dot { background: var(--error-dot); } .main { flex: 1; diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index eb95585..c2b80b2 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -36,7 +36,7 @@ export default function App() { // вкладок (юзеры, ключи) грузят сами вкладки. const startSession = useCallback(async (tok: string) => { const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } }); - setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0 }); + setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2' }); setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?')); }, []); diff --git a/awg-ui/src/components/ServerBar.tsx b/awg-ui/src/components/ServerBar.tsx index 96fb1c2..85ca81c 100644 --- a/awg-ui/src/components/ServerBar.tsx +++ b/awg-ui/src/components/ServerBar.tsx @@ -1,4 +1,6 @@ - +// 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 ServerInfo } from '../lib/shared'; import { IcoPlus, IcoRefresh } from './icons'; diff --git a/awg-ui/src/components/icons.tsx b/awg-ui/src/components/icons.tsx index f792258..dd79bec 100644 --- a/awg-ui/src/components/icons.tsx +++ b/awg-ui/src/components/icons.tsx @@ -1,3 +1,6 @@ +// 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. // Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда // её иконку и зарегистрируй имя в ICONS (его указывают в tabs//metadata.json). diff --git a/awg-ui/src/lib/shared.ts b/awg-ui/src/lib/shared.ts index bc063e4..76da20e 100644 --- a/awg-ui/src/lib/shared.ts +++ b/awg-ui/src/lib/shared.ts @@ -1,10 +1,16 @@ +// 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 axios from 'axios'; +// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ. +export type AwgGen = '2' | '3.1'; export interface User { name: string; ip: string; pub_key: string; vpn_key: string; + key_gen: AwgGen; online: boolean; lastHandshake: number; rx?: number; @@ -14,6 +20,7 @@ export interface ServerInfo { name: string; ip: string; peers: number; + gen: AwgGen; } export interface ApiKey { id: number; diff --git a/awg-ui/src/main.tsx b/awg-ui/src/main.tsx index e2a6eb0..3d0bd24 100644 --- a/awg-ui/src/main.tsx +++ b/awg-ui/src/main.tsx @@ -1,3 +1,6 @@ +// 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 { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; diff --git a/awg-ui/src/tabs/apikeys/apikeys.css b/awg-ui/src/tabs/apikeys/apikeys.css index d8ec725..af9403a 100644 --- a/awg-ui/src/tabs/apikeys/apikeys.css +++ b/awg-ui/src/tabs/apikeys/apikeys.css @@ -1,3 +1,8 @@ +/* +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. +*/ /* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */ /* Открытый API-ключ (показывается один раз при создании) */ diff --git a/awg-ui/src/tabs/apikeys/index.tsx b/awg-ui/src/tabs/apikeys/index.tsx index 640db77..af5f82a 100644 --- a/awg-ui/src/tabs/apikeys/index.tsx +++ b/awg-ui/src/tabs/apikeys/index.tsx @@ -1,3 +1,6 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. import { useState, useEffect, useCallback } from 'react'; import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared'; import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons'; diff --git a/awg-ui/src/tabs/index.ts b/awg-ui/src/tabs/index.ts index 0e781a6..270af99 100644 --- a/awg-ui/src/tabs/index.ts +++ b/awg-ui/src/tabs/index.ts @@ -1,3 +1,6 @@ +// 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. // Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs//: // index.tsx — компонент + логика // .css — стили вкладки (импортит сам компонент) diff --git a/awg-ui/src/tabs/users/index.tsx b/awg-ui/src/tabs/users/index.tsx index f411204..6abd112 100644 --- a/awg-ui/src/tabs/users/index.tsx +++ b/awg-ui/src/tabs/users/index.tsx @@ -1,8 +1,11 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. import { useState, useEffect, useRef, useCallback } from 'react'; import QRCode from 'qrcode'; import { apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, - type User, type PageProps, + type User, type PageProps, type AwgGen, } from '../../lib/shared'; import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; import './users.css'; @@ -13,7 +16,11 @@ export default function UsersPage({ token, showMsg }: PageProps) { const [users, setUsers] = useState([]); const [newName, setNewName] = useState(''); const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null); + // Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом + // поколении, уже не подключатся — их надо перевыпустить. + const [serverGen, setServerGen] = useState('2'); const statsRef = useRef | null>(null); + const stale = users.filter(u => u.key_gen !== serverGen); const loadStats = useCallback(async (tok: string) => { try { @@ -27,14 +34,34 @@ export default function UsersPage({ token, showMsg }: PageProps) { const loadUsers = useCallback(async (tok: string) => { try { - const data = await apiFetch('GET', '/api/users', tok); + const [data, health] = await Promise.all([ + apiFetch('GET', '/api/users', tok), + apiFetch('GET', '/health', tok), + ]); setUsers(data.users ?? []); + setServerGen(health.gen ?? '2'); await loadStats(tok); } catch { showMsg('Ошибка загрузки'); } }, [loadStats, showMsg]); + // Пересобирает vpn:// ключи всех пользователей на текущих параметрах интерфейса. + // IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ. + const reissueKeys = useCallback(async () => { + if (!confirm( + `Перевыпустить ключи (${stale.length} шт.) на поколении ${serverGen}?\n\n` + + 'Старые ключи перестанут работать — всем придётся импортировать ключ заново.', + )) return; + try { + const r = await apiFetch('POST', '/api/users/reissue', token); + showMsg(`Перевыпущено: ${r.reissued} из ${r.total}`); + await loadUsers(token); + } catch { + showMsg('Не удалось перевыпустить ключи'); + } + }, [stale.length, serverGen, token, loadUsers, showMsg]); + const createUser = useCallback(async () => { if (!newName) return; const u = await apiFetch('POST', '/api/users', token, { name: newName }); @@ -93,6 +120,11 @@ export default function UsersPage({ token, showMsg }: PageProps) { + {stale.length > 0 && ( + + )} @@ -125,7 +157,20 @@ export default function UsersPage({ token, showMsg }: PageProps) { : 'никогда'} - {u.name} + + {u.name} + {u.key_gen !== serverGen && ( + + + + AWG {u.key_gen} + + + )} + {u.ip} {bytes(u.rx)} {bytes(u.tx)} @@ -159,6 +204,12 @@ export default function UsersPage({ token, showMsg }: PageProps) {
#{i + 1} {u.name} + {u.key_gen !== serverGen && ( + + + AWG {u.key_gen} + + )}
diff --git a/awg-ui/src/tabs/users/users.css b/awg-ui/src/tabs/users/users.css index 95131ae..9d50ea3 100644 --- a/awg-ui/src/tabs/users/users.css +++ b/awg-ui/src/tabs/users/users.css @@ -1,3 +1,8 @@ +/* +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. +*/ /* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field, table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */ @@ -64,7 +69,12 @@ .user-card-actions { display: flex; gap: 8px; } .user-card-actions .btn--outline { flex: 1; } +/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */ +.user-gen { margin-left: 8px; vertical-align: middle; } + /* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */ @media (max-width: 640px) { .user-cards-list { display: flex; } + /* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */ + .user-gen { width: auto; } } diff --git a/awg-ui/src/vite-env.d.ts b/awg-ui/src/vite-env.d.ts index 11f02fe..d0bf718 100644 --- a/awg-ui/src/vite-env.d.ts +++ b/awg-ui/src/vite-env.d.ts @@ -1 +1,4 @@ +// 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. /// diff --git a/awg-ui/vite.config.ts b/awg-ui/vite.config.ts index 2fb20d4..7a7b199 100644 --- a/awg-ui/vite.config.ts +++ b/awg-ui/vite.config.ts @@ -1,3 +1,6 @@ +// 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 { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; diff --git a/install.sh b/install.sh index c5fa16b..3ffadb3 100644 --- a/install.sh +++ b/install.sh @@ -118,6 +118,10 @@ echo # Ограничения, которые обязан соблюсти генератор: # - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU # - S1, S2: < ~150, в части версий S1 != S2 +# - S1–S4: при заданном HeaderProtectionKey (AmneziaWG 3.x) НИ ОДНО из них +# не может быть меньше 12 (HEADER_PROTECTION_NONCE_SIZE). Модуль на +# нарушение отвечает только «Invalid argument», причина видна лишь при +# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control` # - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные # типы сообщений WireGuard), большие uint32 без пересечений # - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами @@ -447,6 +451,48 @@ else ok "AWG установлен, модуль amneziawg собран и загружается" fi +# Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5. +# - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0, +# поэтому сначала пробуем обновиться. +# - ядро: header protection использует библиотечный chacha-API +# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся +# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. +# Если 3.1 недоступен — не падаем, а пишем 2.0-конфиг: awg-ctrl определяет +# поколение по наличию HeaderProtectionKey в конфиге, так что всё продолжит +# работать ровно как раньше. +AWG3="y" + +kernel_lt_5_5() { + local maj min + maj=${KERNEL%%.*} + min=${KERNEL#*.}; min=${min%%.*} + [[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]] +} + +MOD_VER=$(modinfo -F version amneziawg 2>/dev/null || echo "") +if [[ "${MOD_VER%%.*}" != "3" ]]; then + warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить." + apt-get update >/dev/null 2>&1 || true + DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ + apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true + MOD_VER=$(modinfo -F version amneziawg 2>/dev/null || echo "") +fi + +if [[ "${MOD_VER%%.*}" != "3" ]]; then + AWG3="n" + warn "Модуль остался на версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0." +elif kernel_lt_5_5; then + AWG3="n" + warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." + warn "Ставим сервер на AmneziaWG 2.0." +fi + +if [[ "$AWG3" == "y" ]]; then + ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)" +else + warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль и переустанови, чтобы перейти на 3.1." +fi + step "2/7 Node.js" if command -v node &>/dev/null; then @@ -518,6 +564,38 @@ grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \ sysctl -qp ok "IP forwarding включён" +# Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента +# AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились. +# RandomTrailers/DisableCookies (фичи 3.1 от 12.08.2026) пока не включаем. +# 🛑 HeaderProtectionKey фиксируется на весь срок жизни сервера наравне с +# J/S/H: он обязан совпадать на обоих концах, и его смена делает невалидными +# все ранее выданные vpn:// ключи. При KEEP_DATA берём существующий. +# Поколение предыдущей установки — нужно, чтобы предупредить о перевыпуске +# ключей при KEEP_DATA. Считать обязательно ДО перезаписи конфига. +PREV_GEN="none" +if [[ -f "$AWG_CONF" ]]; then + if grep -q '^HeaderProtectionKey *=' "$AWG_CONF"; then PREV_GEN="3.1"; else PREV_GEN="2.0"; fi +fi + +AWG3_LINES="" +if [[ "$AWG3" == "y" ]]; then + HEADER_PROTECTION_KEY="" + if [[ "$KEEP_DATA" == "y" && -f "$AWG_CONF" ]]; then + # sed, а не awk -F'=': base64-ключ сам содержит '=' в паддинге. + HEADER_PROTECTION_KEY=$(sed -n 's/^HeaderProtectionKey *= *//p' "$AWG_CONF" | head -1) + [[ -n "$HEADER_PROTECTION_KEY" ]] && ok "HeaderProtectionKey взят из существующего конфига" + fi + [[ -z "$HEADER_PROTECTION_KEY" ]] && HEADER_PROTECTION_KEY=$(awg genpsk) + + AWG3_LINES="HeaderProtectionKey = ${HEADER_PROTECTION_KEY} +ContentPaddingAddition = 10-100 +RekeyAfterTime = 100-120 +RekeyTimeout = 3-7 +RejectAfterTime = 150-180 +KeepaliveTimeout = 5-15 +MaxHandshakeAttempts = 15-20" +fi + cat > "$AWG_CONF" <> "$AWG_CONF" + chmod 600 "$AWG_CONF" -ok "$AWG_CONF" +NEW_GEN=$([[ "$AWG3" == "y" ]] && echo 3.1 || echo 2.0) +ok "$AWG_CONF (AmneziaWG $NEW_GEN)" + +if [[ "$KEEP_DATA" == "y" && "$PREV_GEN" != "none" && "$PREV_GEN" != "$NEW_GEN" ]]; then + warn "Поколение протокола изменилось: $PREV_GEN → $NEW_GEN." + warn "Пользователи сохранены, но их vpn:// ключи собраны на старых параметрах" + warn "и работать перестанут. После запуска открой панель и нажми" + warn "«Перевыпустить ключи», затем раздай пользователям новые ключи." +fi step "5/7 Запуск AWG"