From 923bd87fe9c2115839ba5f756f6c1b9a084799fa Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Fri, 25 Sep 2026 15:49:23 +0300 Subject: [PATCH] Add sen:// subscription with master keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A master key is a sen:// link with a device limit. Each device generates its own WireGuard keypair and registers only the public key, then polls a signed /sub/v1/config, so params and generation changes reach users without re-importing a key. - awg-ctrl: peers table and /api/peers*, /api/profile; nextIp and rebuildConf take peers into account (output unchanged without them) - awg-ui: link codec and signatures (sen.ts), registry, panel routes and the public listener on SUB_PORT (sub.ts) - responses are always Ed25519-signed; TLS with a pinned self-signed certificate is optional - devices report a client version, shown as platform-version - "Мастер-ключи" tab: a table with the devices nested under each key, a device-limit meter, per-key settings and a placeholder for the servers a key serves; cards on mobile - install.sh/CLI: subscription signing key, optional certificate, port; kept across reinstalls with KEEP_DATA - add the missing mock-server.ts used by npm run dev:mock --- awg-ctrl/index.ts | 180 ++++++++- awg-ui/mock-server.ts | 84 +++++ awg-ui/sen.ts | 234 ++++++++++++ awg-ui/server.ts | 10 +- awg-ui/src/App.css | 2 + awg-ui/src/components/icons.tsx | 7 + awg-ui/src/tabs/masterkeys/index.tsx | 343 +++++++++++++++++ awg-ui/src/tabs/masterkeys/masterkeys.css | 281 ++++++++++++++ awg-ui/src/tabs/masterkeys/metadata.json | 6 + awg-ui/sub.ts | 434 ++++++++++++++++++++++ cli/src/index.ts | 7 + install.sh | 62 +++- 12 files changed, 1643 insertions(+), 7 deletions(-) create mode 100644 awg-ui/mock-server.ts create mode 100644 awg-ui/sen.ts create mode 100644 awg-ui/src/tabs/masterkeys/index.tsx create mode 100644 awg-ui/src/tabs/masterkeys/masterkeys.css create mode 100644 awg-ui/src/tabs/masterkeys/metadata.json create mode 100644 awg-ui/sub.ts diff --git a/awg-ctrl/index.ts b/awg-ctrl/index.ts index 45599d3..2dfbc81 100644 --- a/awg-ctrl/index.ts +++ b/awg-ctrl/index.ts @@ -87,6 +87,20 @@ db.exec(` ) `); +// Пиры устройств sen://-подписки. Отдельная таблица, users не трогаем: у этих +// пиров нет ни vpn_key, ни имени — приватный ключ живёт только на устройстве, +// сюда приходит лишь публичный. owner — непрозрачная метка от awg-ui +// («m/d»), нужна только для комментария в conf и логов. +db.exec(` + CREATE TABLE IF NOT EXISTS peers ( + pub_key TEXT PRIMARY KEY, + ip TEXT NOT NULL UNIQUE, + psk_key TEXT NOT NULL, + owner TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + ) +`); + const cfgStmts = { get: db.prepare<[string], { value: string }>("SELECT value FROM config WHERE key = ?"), set: db.prepare<[string, string]>("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"), @@ -276,12 +290,27 @@ interface UserRow { vpn_key_prev: string; } +interface PeerRow { + pub_key: string; + ip: string; + psk_key: string; + owner: string; + created_at: number; +} + const stmts = { + peersAll: db.prepare<[], PeerRow>("SELECT * FROM peers ORDER BY created_at, rowid"), + peerGet: db.prepare<[string], PeerRow>("SELECT * FROM peers WHERE pub_key = ?"), + peerInsert: db.prepare<[string, string, string, string, number]>("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)"), + peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"), + peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"), + peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"), + userPubTaken: db.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?"), get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"), 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"), + ips: db.prepare<[], { ip: string }>("SELECT ip FROM users UNION SELECT ip FROM peers"), // Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут // смениться, если исходный ключ не удалось разобрать. reissue: db.prepare<[string, string, string, string, string]>( @@ -547,17 +576,22 @@ function getPeersData(): Record - `\n# ${u.name}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}` + // Без записей в peers вывод байт-в-байт прежний. + const peers = [ + ...users.map(u => ({ label: u.name, ...u })), + ...devices.map(d => ({ label: d.owner || "device", ...d })), + ].map(u => + `\n# ${u.label}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}` ).join("\n"); writeFileSync(confFile, iface + "\n" + peers + "\n"); - logger.info("conf rebuilt", { peers: users.length }); + logger.info("conf rebuilt", { peers: users.length + devices.length }); } const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key"; @@ -829,6 +863,89 @@ function removeUser(username: string) { logger.info("user removed", { name: username }); } +// ── Пиры устройств (sen://) ──────────────────────────────────────────────── +// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим +// публичным ключом». Приватного ключа устройства здесь нет и быть не может. +class HttpError extends Error { + constructor(public status: number, message: string) { super(message); } +} + +// Публичный ключ WireGuard: base64 от 32 байт (44 символа, последний «=»). +function checkPubKey(k: unknown): string { + if (typeof k !== "string" || !/^[A-Za-z0-9+/]{43}=$/.test(k) || Buffer.from(k, "base64").length !== 32) + throw new HttpError(400, "Неверный публичный ключ"); + return k; +} + +// В пути base64url: «/» и «+» обычного base64 в URL ломают маршрут. +function pubKeyFromParam(p: string): string { + return checkPubKey(Buffer.from(p, "base64url").toString("base64")); +} + +function assertPubKeyFree(pub: string) { + if (stmts.peerGet.get(pub) || stmts.userPubTaken.get(pub)!.n > 0 || pub === getServerPublicKey()) + throw new HttpError(409, "Публичный ключ уже используется"); +} + +function addPeer(pubKey: string, owner: string): { ip: string; psk_key: string } { + checkPubKey(pubKey); + const psk = run("awg genpsk"); + const ip = db.transaction(() => { + assertPubKeyFree(pubKey); + const ip = nextIp(); + stmts.peerInsert.run(pubKey, ip, psk, owner, Math.floor(Date.now() / 1000)); + return ip; + })(); + + const r = setPeer(pubKey, psk, ip); + if (r.status !== 0) { + stmts.peerDelete.run(pubKey); + throw new Error(`awg set failed: ${r.stderr?.toString()}`); + } + rebuildConf(); + logger.info("peer added", { owner, ip }); + return { ip, psk_key: psk }; +} + +// Смена ключа устройства: IP и PSK остаются, меняется только pub_key. +function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: string } { + checkPubKey(newPub); + const old = stmts.peerGet.get(oldPub); + if (!old) throw new HttpError(404, "Пир не найден"); + db.transaction(() => { assertPubKeyFree(newPub); stmts.peerRekey.run(newPub, oldPub); })(); + + const r = setPeer(newPub, old.psk_key, old.ip); + if (r.status !== 0) { + stmts.peerRekey.run(oldPub, newPub); + throw new Error(`awg set failed: ${r.stderr?.toString()}`); + } + spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]); + rebuildConf(); + logger.info("peer rekeyed", { owner: old.owner, ip: old.ip }); + return { ip: old.ip, psk_key: old.psk_key }; +} + +function rotatePeerPsk(pub: string): { ip: string; psk_key: string } { + const peer = stmts.peerGet.get(pub); + if (!peer) throw new HttpError(404, "Пир не найден"); + const psk = run("awg genpsk"); + const r = setPeer(pub, psk, peer.ip); + if (r.status !== 0) throw new Error(`awg set failed: ${r.stderr?.toString()}`); + stmts.peerPsk.run(psk, pub); + rebuildConf(); + logger.info("peer psk rotated", { owner: peer.owner }); + return { ip: peer.ip, psk_key: psk }; +} + +function removePeer(pub: string) { + const peer = stmts.peerGet.get(pub); + if (!peer) throw new HttpError(404, "Пир не найден"); + stmts.peerDelete.run(pub); + spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]); + rebuildConf(); + logger.info("peer removed", { owner: peer.owner }); +} + const app = express(); app.use(express.json({ limit: "1kb" })); @@ -863,7 +980,10 @@ function validateName(req: Request, res: Response, next: NextFunction) { function handler(fn: (req: Request, res: Response) => void | Promise) { return async (req: Request, res: Response) => { try { await fn(req, res); } - catch (e) { logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" }); } + catch (e) { + if (e instanceof HttpError) { res.status(e.status).json({ error: e.message }); return; } + logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" }); + } }; } @@ -931,6 +1051,56 @@ app.delete("/api/users/:name", auth, validateName, handler((req, res) => { res.json({ success: true, name: req.params.name }); })); +app.post("/api/peers", auth, handler((req, res) => { + const { pub_key, owner } = (req.body ?? {}) as { pub_key?: string; owner?: string }; + if (typeof owner !== "string" || !/^[\w/.-]{0,64}$/.test(owner)) throw new HttpError(400, "Неверный owner"); + res.status(201).json(addPeer(checkPubKey(pub_key), owner)); +})); + +app.get("/api/peers", auth, handler((_req, res) => { + const stats = getPeersData(); + res.json({ + peers: stmts.peersAll.all().map(p => ({ + pub_key: p.pub_key, ip: p.ip, psk_key: p.psk_key, owner: p.owner, + online: stats[p.pub_key]?.online ?? false, + lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0, + rx: stats[p.pub_key]?.rx ?? 0, + tx: stats[p.pub_key]?.tx ?? 0, + })), + }); +})); + +app.put("/api/peers/:pub", auth, handler((req, res) => { + const { pub_key } = (req.body ?? {}) as { pub_key?: string }; + res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key))); +})); + +app.post("/api/peers/:pub/psk", auth, handler((req, res) => { + res.json(rotatePeerPsk(pubKeyFromParam(req.params.pub))); +})); + +app.delete("/api/peers/:pub", auth, handler((req, res) => { + removePeer(pubKeyFromParam(req.params.pub)); + res.json({ success: true }); +})); + +// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf; +// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf. +app.get("/api/profile", auth, handler((_req, res) => { + const awg: Record = {}; + for (const [k, v] of Object.entries(CONFIG.awgParams)) if (String(v) !== "") awg[k] = String(v); + res.json({ + name: CONFIG.serverName, + endpoint: `${CONFIG.serverIp}:${CONFIG.serverPort}`, + server_pub: getServerPublicKey(), + gen: CONFIG.gen, + dns: [CONFIG.dns1, CONFIG.dns2], + keepalive: CONFIG.keepalive, + mtu: CONFIG.mtu, + awg, + }); +})); + app.post("/awg/restart", auth, handler((_req, res) => { restartAwg(); res.json({ success: true }); diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts new file mode 100644 index 0000000..15e817a --- /dev/null +++ b/awg-ui/mock-server.ts @@ -0,0 +1,84 @@ +// 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. +// +// Локальный мок для разработки фронтенда: эмулирует awg-ui + awg-ctrl целиком в +// памяти, AWG не нужен. Логин admin / admin. Запуск: npm run dev:mock. +import express from "express"; +import crypto from "crypto"; +import { encodeSenLink } from "./sen"; + +const app = express(); +app.use(express.json()); + +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"; + +// ── пользователи (vpn://) ── +interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number } +const users: U[] = ["alice", "bob", "laptop-ivan"].map((name, i) => ({ + name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), + key_gen: GEN, online: i !== 1, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8), +})); + +// ── API-ключи ── +const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }]; +let apiId = 1; + +// ── мастер-ключи sen:// ── +interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number } +interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[] } +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++, 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")] }]; +const signPub = crypto.randomBytes(32); +const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, server_id: 0, created_at: m.created_at }); +const find = (id: string) => masters.find(m => m.id === Number(id)); + +app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" })); +app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" })); +app.post("/logout", (_q, r) => r.json({ ok: true })); +app.get("/health", (_q, r) => r.json({ status: "ok", server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, awg: { status: "ok", peers: users.length, module: "3.0.1", tools: "1.0.0" } })); +app.post("/awg/restart", (_q, r) => r.json({ up: true })); +app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length })); + +app.get("/api/users", (_q, r) => r.json({ users })); +app.get("/api/users/stats", (_q, r) => r.json({ users: users.map(({ vpn_key, pub_key, ...u }) => u) })); +app.post("/api/users/reissue", (_q, r) => r.json({ total: users.length, reissued: users.length, regenerated: [], backup: "" })); +app.post("/api/users", (q, r) => { + const name = String(q.body.name ?? ""); + if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" }); + if (users.some(u => u.name === name)) return r.status(409).json({ error: "Пользователь уже существует" }); + const u: U = { name, ip: `10.9.0.${users.length + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), key_gen: GEN, online: false, lastHandshake: 0, rx: 0, tx: 0 }; + users.push(u); r.status(201).json(u); +}); +app.post("/api/users/:name", (q, r) => { const u = users.find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); }); +app.delete("/api/users/:name", (q, r) => { const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); }); + +app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys })); +app.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); }); +app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); }); + +app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) })); +app.post("/ui/masterkeys", (q, r) => { + const limit = q.body.device_limit ?? 3; + if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" }); + const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [] }; + masters.push(m); r.status(201).json(mkOut(m)); +}); +app.patch("/ui/masterkeys/:id", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m)); }); +app.post("/ui/masterkeys/:id/rotate", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.secret = crypto.randomBytes(16); r.json({ id: m.id }); }); +app.post("/ui/masterkeys/:id/rekey", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.devs.forEach(d => d.rekey_requested = true); r.json({ id: m.id }); }); +app.delete("/ui/masterkeys/:id", (q, r) => { const i = masters.findIndex(m => m.id === Number(q.params.id)); if (i >= 0) masters.splice(i, 1); r.json({ success: true }); }); +app.get("/ui/masterkeys/:id/link", (q, r) => { + const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); + r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false }); +}); +app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs }) : r.status(404).json({ error: "Не найден" }); }); +app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); }); +app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); }); +app.post("/ui/devices/:id/psk", (_q, r) => r.json({})); + +app.listen(8080, () => console.log("mock: http://localhost:8080 (login admin / admin)")); diff --git a/awg-ui/sen.ts b/awg-ui/sen.ts new file mode 100644 index 0000000..76e8e69 --- /dev/null +++ b/awg-ui/sen.ts @@ -0,0 +1,234 @@ +// 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. +// +// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1. +// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент +// реализуется по ней, поэтому любое изменение здесь — изменение протокола. +import crypto from "crypto"; + +export const SEN_PREFIX = "sen://"; +export const SEN_VERSION = 1; +export const FLAG_TLS = 0x01; + +const ADDR_V4 = 0x04; +const ADDR_V6 = 0x06; +const ADDR_DOMAIN = 0x44; + +export class SenLinkError extends Error {} + +export interface SenAddr { host: string; port: number } + +export interface SenLink { + tls: boolean; + addrs: SenAddr[]; + secret: Buffer; // 16 байт + signPub: Buffer; // 32 байта, Ed25519 + tlsPin?: Buffer; // 32 байта, только при tls + name: string; +} + +// ── CRC32 (IEEE) ─────────────────────────────────────────────────────────── +// zlib.crc32 появился только в Node 20.15, а целимся в 20.x целиком. +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +export function crc32(buf: Uint8Array): number { + let c = 0xffffffff; + for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +// ── Адреса ───────────────────────────────────────────────────────────────── +function ipv6ToBytes(host: string): Buffer | null { + const parts = host.split("::"); + if (parts.length > 2) return null; + const head = parts[0] ? parts[0].split(":") : []; + const tail = parts.length === 2 && parts[1] ? parts[1].split(":") : []; + const missing = 8 - head.length - tail.length; + if (parts.length === 1 ? head.length !== 8 : missing < 1) return null; + const groups = [...head, ...Array(parts.length === 2 ? missing : 0).fill("0"), ...tail]; + if (groups.length !== 8) return null; + const out = Buffer.alloc(16); + for (let i = 0; i < 8; i++) { + if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null; + out.writeUInt16BE(parseInt(groups[i], 16), i * 2); + } + return out; +} + +function bytesToIpv6(b: Buffer): string { + const g: string[] = []; + for (let i = 0; i < 16; i += 2) g.push(b.readUInt16BE(i).toString(16)); + // Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи. + let bestStart = -1, bestLen = 0; + for (let i = 0; i < 8;) { + if (g[i] !== "0") { i++; continue; } + let j = i; + while (j < 8 && g[j] === "0") j++; + if (j - i > bestLen) { bestStart = i; bestLen = j - i; } + i = j; + } + if (bestLen < 2) return g.join(":"); + return g.slice(0, bestStart).join(":") + "::" + g.slice(bestStart + bestLen).join(":"); +} + +function encodeAddr({ host, port }: SenAddr): Buffer { + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new SenLinkError(`Неверный порт: ${port}`); + const p = Buffer.alloc(2); + p.writeUInt16BE(port); + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) { + const o = host.split(".").map(Number); + if (o.some(n => n > 255)) throw new SenLinkError(`Неверный IPv4: ${host}`); + return Buffer.concat([Buffer.from([ADDR_V4, ...o]), p]); + } + if (host.includes(":")) { + const b = ipv6ToBytes(host); + if (!b) throw new SenLinkError(`Неверный IPv6: ${host}`); + return Buffer.concat([Buffer.from([ADDR_V6]), b, p]); + } + const h = Buffer.from(host, "utf8"); + if (h.length < 1 || h.length > 253 || !/^[A-Za-z0-9.-]+$/.test(host)) + throw new SenLinkError(`Неверный домен: ${host}`); + return Buffer.concat([Buffer.from([ADDR_DOMAIN, h.length]), h, p]); +} + +// ── Ссылка ───────────────────────────────────────────────────────────────── +export function encodeSenLink(l: SenLink): string { + if (l.addrs.length < 1 || l.addrs.length > 3) throw new SenLinkError("Адресов должно быть от 1 до 3"); + if (l.secret.length !== 16) throw new SenLinkError("secret: 16 байт"); + if (l.signPub.length !== 32) throw new SenLinkError("signPub: 32 байта"); + if (l.tls && l.tlsPin?.length !== 32) throw new SenLinkError("tlsPin: 32 байта при TLS"); + const name = Buffer.from(l.name, "utf8"); + if (name.length > 64) throw new SenLinkError("Имя: до 64 байт"); + + const body = Buffer.concat([ + Buffer.from([SEN_VERSION, l.tls ? FLAG_TLS : 0, l.addrs.length]), + ...l.addrs.map(encodeAddr), + l.secret, + l.signPub, + ...(l.tls ? [l.tlsPin as Buffer] : []), + Buffer.from([name.length]), + name, + ]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return SEN_PREFIX + Buffer.concat([body, crc]).toString("base64url"); +} + +export function decodeSenLink(link: string): SenLink { + const t = link.trim(); + if (!t.toLowerCase().startsWith(SEN_PREFIX)) throw new SenLinkError("Ссылка должна начинаться с sen://"); + const payload = t.slice(SEN_PREFIX.length).replace(/\s+/g, ""); + if (!/^[A-Za-z0-9_-]+$/.test(payload)) throw new SenLinkError("Ссылка повреждена: недопустимые символы"); + const raw = Buffer.from(payload, "base64url"); + if (raw.length < 3 + 4) throw new SenLinkError("Ссылка слишком короткая"); + + const body = raw.subarray(0, raw.length - 4); + if (raw.readUInt32BE(raw.length - 4) !== crc32(body)) throw new SenLinkError("Ссылка повреждена: не сошлась контрольная сумма"); + + let off = 0; + const need = (n: number) => { if (off + n > body.length) throw new SenLinkError("Ссылка обрезана"); }; + const take = (n: number) => { need(n); const s = body.subarray(off, off + n); off += n; return s; }; + + const version = take(1)[0]; + if (version !== SEN_VERSION) throw new SenLinkError(`Неизвестная версия ссылки: ${version}`); + const flags = take(1)[0]; + if (flags & ~FLAG_TLS) throw new SenLinkError("Неизвестные флаги ссылки"); + const n = take(1)[0]; + if (n < 1 || n > 3) throw new SenLinkError("Неверное число адресов"); + + const addrs: SenAddr[] = []; + for (let i = 0; i < n; i++) { + const type = take(1)[0]; + let host: string; + if (type === ADDR_V4) host = [...take(4)].join("."); + else if (type === ADDR_V6) host = bytesToIpv6(Buffer.from(take(16))); + else if (type === ADDR_DOMAIN) { + const len = take(1)[0]; + if (len < 1) throw new SenLinkError("Пустой домен"); + host = Buffer.from(take(len)).toString("utf8"); + if (!/^[A-Za-z0-9.-]+$/.test(host)) throw new SenLinkError("Неверный домен"); + } else throw new SenLinkError(`Неизвестный тип адреса: ${type}`); + const port = Buffer.from(take(2)).readUInt16BE(0); + if (port === 0) throw new SenLinkError("Порт 0"); + addrs.push({ host, port }); + } + + const secret = Buffer.from(take(16)); + const signPub = Buffer.from(take(32)); + const tls = (flags & FLAG_TLS) !== 0; + const tlsPin = tls ? Buffer.from(take(32)) : undefined; + const nameLen = take(1)[0]; + if (nameLen > 64) throw new SenLinkError("Имя длиннее 64 байт"); + const name = Buffer.from(take(nameLen)).toString("utf8"); + if (off !== body.length) throw new SenLinkError("Лишние данные в конце ссылки"); + + return { tls, addrs, secret, signPub, tlsPin, name }; +} + +// ── Ключи Ed25519 в «сыром» виде (32 байта) ──────────────────────────────── +const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export function rawPublicKey(key: crypto.KeyObject): Buffer { + const der = key.export({ type: "spki", format: "der" }) as Buffer; + return der.subarray(der.length - 32); +} + +export function publicKeyFromRaw(raw: Buffer): crypto.KeyObject { + if (raw.length !== 32) throw new SenLinkError("Публичный ключ Ed25519: 32 байта"); + return crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" }); +} + +/** SHA-256 от SPKI DER сертификата — то, что кладётся в ссылку как pin. */ +export function spkiPin(certPem: string): Buffer { + const spki = new crypto.X509Certificate(certPem).publicKey.export({ type: "spki", format: "der" }) as Buffer; + return crypto.createHash("sha256").update(spki).digest(); +} + +// ── Подписи протокола ────────────────────────────────────────────────────── +// Ответ: {"body": "", "sig": ""}; sig — Ed25519 над +// UTF-8 байтами строки body. body — именно строка, а не вложенный объект, +// чтобы клиенту не нужна была каноническая сериализация JSON. +export function signResponse(payload: unknown, signKey: crypto.KeyObject): { body: string; sig: string } { + const body = JSON.stringify(payload); + return { body, sig: crypto.sign(null, Buffer.from(body, "utf8"), signKey).toString("base64url") }; +} + +export function verifyResponse(res: { body: string; sig: string }, signPub: Buffer): boolean { + try { + return crypto.verify(null, Buffer.from(res.body, "utf8"), publicKeyFromRaw(signPub), Buffer.from(res.sig, "base64url")); + } catch { return false; } +} + +// Запрос: подпись ключом устройства (auth_pub) над строкой +// METHOD \n /path \n ts \n hex(sha256(тело запроса)) +// Тело пустое → sha256 пустой строки. path — без query. +export function requestSigString(method: string, urlPath: string, ts: number, body: Buffer | string): string { + const h = crypto.createHash("sha256").update(body).digest("hex"); + return `${method.toUpperCase()}\n${urlPath}\n${ts}\n${h}`; +} + +export function signRequest( + method: string, urlPath: string, ts: number, body: Buffer | string, authKey: crypto.KeyObject, +): string { + return crypto.sign(null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"), authKey).toString("base64url"); +} + +export function verifyRequest( + method: string, urlPath: string, ts: number, body: Buffer | string, sig: string, authPub: Buffer, +): boolean { + try { + return crypto.verify( + null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"), + publicKeyFromRaw(authPub), Buffer.from(sig, "base64url"), + ); + } catch { return false; } +} diff --git a/awg-ui/server.ts b/awg-ui/server.ts index eb493b7..217271f 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -7,6 +7,7 @@ import fs from "fs"; import crypto from "crypto"; import axios from "axios"; import Database from "better-sqlite3"; +import { createSub } from "./sub"; const app = express(); const PORT = Number(process.env.PORT) || 8080; @@ -279,6 +280,12 @@ ext.delete("/users/:name", wrap(async (req, res) => { // его и потребует JWT вместо ключа. app.use("/api/v1", ext); +// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный +// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется. +const sub = createSub({ uidb, ctrl, baseDir: __dirname }); +app.use("/ui/masterkeys", requireAuth, sub.masterKeys); +app.use("/ui/devices", requireAuth, sub.devices); + app.use(["/api", "/health", "/awg"], requireAuth, proxy); app.use(express.static(path.join(__dirname, "dist"))); @@ -287,4 +294,5 @@ app.get("*", (_req: Request, res: Response) => { res.sendFile(path.join(__dirname, "dist", "index.html")); }); -app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); }); \ No newline at end of file +app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); }); +sub.start(); \ No newline at end of file diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index 54ebcfe..e5c268c 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -24,6 +24,7 @@ html, body, #root { height: 100%; } --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; --danger-border: #e6c4bc; --danger-text: #b34a3a; @@ -65,6 +66,7 @@ html, body, #root { height: 100%; } --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; --danger-border: #6e4540; --danger-text: #ffb4ab; diff --git a/awg-ui/src/components/icons.tsx b/awg-ui/src/components/icons.tsx index dd79bec..edd61e3 100644 --- a/awg-ui/src/components/icons.tsx +++ b/awg-ui/src/components/icons.tsx @@ -35,6 +35,13 @@ export const IcoRefresh = () => ( ); +export const IcoSettings = () => ( + + + + + +); export const IcoQR = () => ( diff --git a/awg-ui/src/tabs/masterkeys/index.tsx b/awg-ui/src/tabs/masterkeys/index.tsx new file mode 100644 index 0000000..2f43c07 --- /dev/null +++ b/awg-ui/src/tabs/masterkeys/index.tsx @@ -0,0 +1,343 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import { Fragment, useState, useEffect, useCallback } from 'react'; +import QRCode from 'qrcode'; +import { apiFetch, copyText, bytes, timeAgo, genLabel, type AwgGen, type PageProps } from '../../lib/shared'; +import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons'; +import './masterkeys.css'; + +// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка +// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся +// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md. +interface MasterKey { id: number; label: string; device_limit: number; devices: number; created_at: number } +interface Device { + id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; + rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; +} + +// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа. +const platformLabel = (d: Device): string => + d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—'; + +const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса'; + +export default function MasterKeysPage({ token, showMsg }: PageProps) { + const [keys, setKeys] = useState([]); + const [enabled, setEnabled] = useState(true); + const [tls, setTls] = useState(false); + const [server, setServer] = useState<{ name: string; ip: string; gen: string } | null>(null); + const [label, setLabel] = useState(''); + const [limit, setLimit] = useState('3'); + const [devs, setDevs] = useState>({}); + const [folded, setFolded] = useState>(new Set()); // свёрнутые узлы + const [tools, setTools] = useState(null); // узел с открытыми настройками + const [limitEdit, setLimitEdit] = useState(''); + const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null); + + const load = useCallback(async () => { + try { + const d = await apiFetch('GET', '/ui/masterkeys', token); + const list: MasterKey[] = d.keys ?? []; + setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); + // Сервер, который сейчас отдаётся по ключу (он пока единственный). + apiFetch('GET', '/health', token).then(h => setServer({ name: h.server, ip: h.ip, gen: h.gen })).catch(() => {}); + const all = await Promise.all(list.map(k => + apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const))); + setDevs(Object.fromEntries(all)); + } catch { showMsg('Ошибка загрузки мастер-ключей'); } + }, [token, showMsg]); + + // Действие над ключом/устройством: выполнить, показать сообщение, обновить данные. + const act = useCallback(async (fn: () => Promise, ok: string) => { + try { await fn(); showMsg(ok); await load(); } + catch (e) { showMsg(errText(e)); } + }, [load, showMsg]); + + const create = useCallback(async () => { + if (!label.trim()) return; + await act(async () => { + await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit) }); + setLabel(''); + }, 'Мастер-ключ создан'); + }, [label, limit, token, act]); + + const remove = useCallback(async (k: MasterKey) => { + if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return; + await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label); + }, [token, act]); + + const fetchLink = useCallback(async (k: MasterKey) => { + const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token); + return r as { link: string; tls: boolean }; + }, [token]); + + const showQR = useCallback(async (k: MasterKey) => { + try { + const { link, tls: t } = await fetchLink(k); + const dataUrl = await QRCode.toDataURL(link, { width: 560, margin: 2, errorCorrectionLevel: 'H' }); + setModal({ label: k.label, link, tls: t, dataUrl }); + } catch (e) { showMsg(errText(e)); } + }, [fetchLink, showMsg]); + + const copyLink = useCallback(async (k: MasterKey) => { + try { await copyText((await fetchLink(k)).link); showMsg('Ссылка скопирована'); } + catch (e) { showMsg(errText(e)); } + }, [fetchLink, showMsg]); + + const fold = (id: number) => setFolded(prev => { + const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; + }); + const openTools = (k: MasterKey) => { setLimitEdit(String(k.device_limit)); setTools(tools === k.id ? null : k.id); }; + + useEffect(() => { if (token) load(); }, [token, load]); + + const actions = (k: MasterKey) => ( +
+ + + + +
+ ); + + const toolbox = (k: MasterKey) => ( +
+ {/* Слева — настройки самого ключа */} +
+

Настройки ключа

+
+
+ + Сколько устройств может подключить ключ +
+
+ setLimitEdit(e.target.value)} /> + +
+
+
+
+ Ссылка + Старая перестанет принимать новые устройства +
+
+ +
+
+
+
+ Ключи устройств + Клиенты сменят их при следующем опросе +
+
+ +
+
+
+ + {/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён. + TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[] + уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */} +
+

Включённые серверы

+ + + + +
+
+ ); + + // Полоска занятых слотов: числа по краям внутри; цвет по заполнению. + const meter = (k: MasterKey, n: number) => ( +
= k.device_limit ? ' mk-meter--full' : n * 3 >= k.device_limit * 2 ? ' mk-meter--warn' : ''}`} + role="progressbar" aria-label="Устройства" aria-valuemin={0} + aria-valuemax={k.device_limit} aria-valuenow={n} + title={`Устройств: ${n} из ${k.device_limit}`}> + {n}{k.device_limit} +
+ {/* тот же текст поверх заливки — светлый, чтобы читался на ней */} + +
+
+ ); + + const deviceActions = (d: Device) => ( +
+ + +
+ ); + + const caret = (k: MasterKey) => ( + + ); + + const deviceNode = (k: MasterKey, d: Device) => ( +
  • +
    +
    + + {d.device_name || 'Устройство'} + {d.device_id} + + {platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'} + {d.rekey_requested ? ' · ждёт смены ключа' : ''} +
    + + {d.online ? 'Онлайн' : 'Офлайн'} + +
    + ↓{bytes(d.rx)} + ↑{bytes(d.tx)} +
    + {deviceActions(d)} +
    +
  • + ); + + return ( + <> + {!enabled && ( +

    Подписка не настроена на сервере: нет ключа подписи или SUB_PORT. Ссылки выдавать нельзя.

    + )} + {enabled && !tls && ( +

    Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.

    + )} + +
    + setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} /> + setLimit(e.target.value)} /> +
    + +
    +
    + + {/* Десктоп: таблица, иерархия — строка мастер-ключа и устройства под ней */} +
    + + + + + + + + + {keys.length === 0 ? ( + + ) : keys.map(k => { + const list = devs[k.id] ?? []; + const free = Math.max(0, k.device_limit - list.length); + const open = !folded.has(k.id); + return ( + + + + + + + + {tools === k.id && } + {open && list.map((d, i) => ( + + + + + + + + + + + + ))} + {open && free > 0 && ( + + + + )} + + ); + })} + +
    ИмяID устройстваПлатформаСтатус↓ rx↑ txБылУстройства
    Нет мастер-ключей
    {caret(k)}{k.label}{meter(k, list.length)}{actions(k)}
    {toolbox(k)}
    {d.device_name || 'Устройство'}{d.device_id}{platformLabel(d)} + + {d.online ? 'Онлайн' : 'Офлайн'} + + {bytes(d.rx)}{bytes(d.tx)}{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'} + {d.rekey_requested && · ждёт смены ключа}{deviceActions(d)}
    Свободно: {free}
    +
    + + {/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */} + {keys.length === 0 ?

    Нет мастер-ключей

    : ( +
      + {keys.map(k => { + const list = devs[k.id] ?? []; + const free = Math.max(0, k.device_limit - list.length); + const isFolded = folded.has(k.id); + return ( +
    • +
      + {caret(k)} + + {k.label} + {meter(k, list.length)} + {actions(k)} +
      + {tools === k.id && toolbox(k)} + {!isFolded && ( +
        + {list.map(d => deviceNode(k, d))} + {free > 0 && ( +
      • Свободно: {free}
      • + )} +
      + )} +
    • + ); + })} +
    + )} + + {modal && ( +
    setModal(null)}> +
    e.stopPropagation()}> +

    {modal.label}

    + QR { await copyText(modal.link); showMsg('Ссылка скопирована'); }} /> +

    Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}

    +
    + + +
    +
    +
    + )} + + ); +} diff --git a/awg-ui/src/tabs/masterkeys/masterkeys.css b/awg-ui/src/tabs/masterkeys/masterkeys.css new file mode 100644 index 0000000..6d78c10 --- /dev/null +++ b/awg-ui/src/tabs/masterkeys/masterkeys.css @@ -0,0 +1,281 @@ +/* +Copyright (c) 2026 Ivan Vasilev +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +/* Стили вкладки «Мастер-ключи»: дерево «мастер-ключ → устройства». + Общая дизайн-система — в App.css. */ + +.mk-notice { + font-size: 13px; + color: var(--error-text); + background: var(--error-bg); + border-radius: 16px; + padding: 10px 16px; +} +.mk-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; } +.mk-limit-input { width: 88px !important; } + +/* ── таблица (десктоп) ── */ +.mk-table td, .mk-table th { white-space: nowrap; } +.mk-table .td-actions { width: 150px; } +.mk-table .mk-device-id { display: inline-block; max-width: 170px; vertical-align: middle; } +.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; } +.mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; } +.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; } +.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; } +.mk-tr-master:hover td { background: var(--surface-container); } +.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); } +.mk-name .mk-caret { margin-left: -8px; } +.mk-tr-master .mk-name { color: var(--on-surface); } +.mk-tr-master .mk-name svg { color: var(--primary); } +.mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */ +.mk-tr-tools:hover { background: transparent; } +.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; } +.mk-flag { color: var(--neutral-text); font-size: 12px; } +/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */ +.mk-tr-device td:first-child, .mk-tr-free td:first-child { + background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat; +} +.mk-tr-device.mk-last td:first-child, .mk-tr-free.mk-last td:first-child { background-size: 2px 50%; } +.mk-indent { position: relative; display: inline-block; padding-left: 36px; } +.mk-indent::before { + content: ''; position: absolute; left: 10px; top: 50%; width: 16px; + border-top: 2px solid var(--outline); +} +.mk-tr-free td { color: var(--neutral-dot); font-size: 12px; } +.mk-tr-free:hover { background: transparent; } + +/* ── дерево (мобильный) ── */ +.mk-tree, .mk-children { list-style: none; margin: 0; padding: 0; } +.mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */ + +.mk-root { + background: var(--surface-bright); + border: 1px solid var(--outline-variant); + border-radius: var(--r-card); + padding: 12px 18px 14px; +} + +/* родитель */ +.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; } +.mk-master-label { + font-size: 16px; + font-weight: 500; + color: var(--on-surface); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mk-caret { + width: 28px; height: 28px; + border: 0; border-radius: 50%; + background: transparent; + color: var(--neutral-text); + font-size: 16px; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.15s; +} +.mk-caret:hover { background: var(--state-neutral); } +.mk-caret--folded { transform: rotate(-90deg); } + +.mk-tools { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + align-items: stretch; + margin: 6px 0 8px; +} +/* два одинаковых блока: слева настройки ключа, справа серверы */ +.mk-box { + display: flex; flex-direction: column; gap: 10px; + background: var(--surface-bright); + border: 1px solid var(--outline-variant); + border-radius: 16px; + padding: 12px 16px 14px; +} +.mk-box-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); } +.mk-box .mk-box-title { margin-bottom: 2px; } +/* строка настройки: слева название и пояснение, справа контрол */ +.mk-setting { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 10px 0; + border-top: 1px solid var(--outline-variant); +} +.mk-box-title + .mk-setting { border-top: 0; padding-top: 4px; } +.mk-setting:last-child { padding-bottom: 0; } +.mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); } +.mk-setting-hint { font-size: 12px; color: var(--neutral-text); } +.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; } +.mk-limit-input { height: 36px; width: 80px !important; text-align: center; } + +/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка». + Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */ +.mk-meter { + --mk-w: 136px; + --meter-fill: var(--success-dot); /* < 2/3 — зелёный */ + --meter-on: #2a2118; /* цвет числа поверх заливки */ + position: relative; + width: var(--mk-w); height: 20px; + border-radius: var(--r-pill); + background: var(--outline-variant); + overflow: hidden; + flex-shrink: 0; +} +.mk-meter-fill { + position: absolute; left: 0; top: 0; bottom: 0; + background: var(--meter-fill); + overflow: hidden; + transition: width 0.25s, background 0.25s; +} +/* числа по краям внутри полоски: текущее слева, лимит справа */ +.mk-meter-text { + position: absolute; left: 0; top: 0; + width: var(--mk-w); height: 100%; + box-sizing: border-box; + padding: 0 11px; + display: flex; align-items: center; justify-content: space-between; + font-size: 12px; font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--on-surface); +} +.mk-meter-text--on { color: var(--meter-on); } +.mk-meter--warn { --meter-fill: var(--warn-dot); } /* от 2/3 — оранжевый */ +.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */ +:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; } + +/* серверы, отдаваемые по ключу (заглушка) */ +.mk-server { + display: flex; align-items: center; gap: 10px; + background: var(--surface-container); + border-radius: 12px; + padding: 10px 14px; + font-size: 13px; + color: var(--on-surface); +} +.mk-server input { accent-color: var(--primary); margin: 0; } +.mk-server-name { font-weight: 500; } +.mk-server-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); } +.mk-server-ip { font-family: var(--mono); margin-left: auto; } +.mk-server-add { + width: 100%; justify-content: center; + background: transparent; + border: 1px dashed var(--outline); + color: var(--neutral-dot); + cursor: not-allowed; +} +.mk-box .tip-wrap { width: 100%; } + +/* дети: вертикальная линия слева и «ножка» к каждому узлу */ +.mk-children { margin: 8px 0 0 18px; padding-left: 28px; border-left: 2px solid var(--outline); } +.mk-node { position: relative; padding: 4px 0; } +.mk-node::before { + content: ''; + position: absolute; + left: -28px; top: 50%; + width: 28px; + border-top: 2px solid var(--outline); +} +/* последний узел: линия родителя обрывается на его «ножке» */ +.mk-node:last-child::after { + content: ''; + position: absolute; + left: -30px; top: calc(50% + 1px); bottom: 0; + width: 2px; + background: var(--surface-bright); +} + +.mk-device { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + background: var(--surface-container); + border-radius: 14px; + padding: 8px 14px; +} +.mk-device-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 160px; } +.mk-device-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.mk-device-id { + font-family: var(--mono); + font-size: 11px; + color: var(--neutral-text); + background: var(--surface-bright); + border-radius: var(--r-pill); + padding: 1px 8px; + min-width: 0; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); } +.mk-device-sub { font-size: 12px; color: var(--neutral-text); } +.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); } +.mk-metric { display: inline-flex; gap: 4px; } +.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; } + +.mk-free { + border: 1px dashed var(--outline); + border-radius: 14px; + padding: 10px 14px; + font-size: 13px; + color: var(--neutral-dot); +} + +/* ── мобильный (design.md, часть IV): одна колонка, тач-цели ≥ 44px, rx/tx блоками + 50/50, действия рядом с большим пальцем ── */ +@media (max-width: 640px) { + .mk-tree { display: flex; } + .mk-limit-input { width: 100% !important; } + + .mk-root { padding: 12px; } + + /* родитель: шапка, затем ряд действий на всю ширину */ + .mk-master { flex-wrap: wrap; row-gap: 10px; } + .mk-master .actions { width: 100%; gap: 8px; } + .mk-caret { width: 44px; height: 44px; margin: -6px 0 -6px -8px; } + .mk-meter { --mk-w: 112px; height: 24px; } + + /* настройки узла: поля и кнопки на всю ширину */ + .mk-tools { grid-template-columns: minmax(0, 1fr); margin: 8px 0 4px; } + .mk-setting { flex-direction: column; align-items: stretch; gap: 8px; } + .mk-setting-ctl { width: 100%; } + .mk-setting-ctl .btn { flex: 1; height: 44px; } + .mk-setting-ctl .mk-limit-input { width: 100% !important; flex: 1; height: 44px; } + .mk-tools .btn { width: 100%; height: 44px; justify-content: center; } + + /* дерево уже: отступы поменьше, «ножки» короче */ + .mk-children { margin-left: 8px; padding-left: 14px; } + .mk-node { padding: 4px 0; } + .mk-node::before { left: -14px; width: 14px; } + .mk-node:last-child::after { left: -16px; } + + /* устройство — карточка: заголовок + чип, метрики 50/50, ряд действий */ + .mk-device { border-radius: 16px; padding: 12px; gap: 10px; } + .mk-device-main { flex: 1 1 0; } + .mk-device-title { flex-wrap: wrap; row-gap: 2px; } + .mk-device-id { max-width: 100%; } + .mk-device-traffic { width: 100%; gap: 8px; } + .mk-metric { + flex: 1; flex-direction: column; gap: 2px; + background: var(--surface-bright); + border-radius: 16px; padding: 8px 12px; + } + .mk-metric i { font-family: var(--font, inherit); font-size: 12px; color: var(--neutral-text); } + .mk-metric-name::after { content: attr(data-sfx); } + .mk-metric b { font-size: 16px; font-weight: 500; color: var(--on-surface); } + + /* кнопки действий: контурные, на всю ширину ряда, 44px */ + .mk-device .actions { width: 100%; gap: 8px; } + .mk-master .btn-icon, .mk-device .btn-icon { + flex: 1; width: auto; height: 44px; + border: 1px solid var(--outline); + border-radius: var(--r-pill); + } + .mk-master .btn-icon--danger, .mk-device .btn-icon--danger { border-color: var(--error-dot); } + + .mk-free { padding: 12px; font-size: 13px; } +} diff --git a/awg-ui/src/tabs/masterkeys/metadata.json b/awg-ui/src/tabs/masterkeys/metadata.json new file mode 100644 index 0000000..a3470a6 --- /dev/null +++ b/awg-ui/src/tabs/masterkeys/metadata.json @@ -0,0 +1,6 @@ +{ + "id": "masterkeys", + "label": "Мастер-ключи", + "icon": "key", + "order": 1.5 +} diff --git a/awg-ui/sub.ts b/awg-ui/sub.ts new file mode 100644 index 0000000..385acc0 --- /dev/null +++ b/awg-ui/sub.ts @@ -0,0 +1,434 @@ +// 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. +// +// sen://-подписка: реестр мастер-ключей/устройств (ui.db), админ-роуты панели +// (/ui/masterkeys, /ui/devices, за JWT) и публичный листенер /sub/v1/* на +// отдельном порту SUB_PORT. Протокол и формат ссылки — docs/sen-link.md. +// +// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по +// публичному ключу (/api/peers*) и общий профиль сервера (/api/profile). +import crypto from "crypto"; +import fs from "fs"; +import http from "http"; +import https from "https"; +import path from "path"; +import express, { Request, Response, NextFunction, Router } from "express"; +import type Database from "better-sqlite3"; +import { + encodeSenLink, rawPublicKey, publicKeyFromRaw, spkiPin, + signResponse, verifyRequest, +} from "./sen"; + +type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>; + +interface MasterRow { + id: number; label: string; secret: Buffer; device_limit: number; + server_id: number; created_at: number; revoked_at: number | null; +} +interface DeviceRow { + id: number; master_id: number; device_id: string; device_name: string; platform: string; + pub_key: string; auth_pub: string; created_at: number; last_seen: number | null; + rekey_requested: number; version: string; +} + +const TS_WINDOW = 300; // секунд, ± +const DEFAULT_LIMIT = 3; +const B64_PUB = /^[A-Za-z0-9+/]{43}=$/; +const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная + +// pub_key WireGuard в пути к awg-ctrl — base64url (в обычном base64 есть «/»). +const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url"); + +// ── Простой лимитер по IP ────────────────────────────────────────────────── +function makeLimiter(max: number, windowMs: number) { + const hits = new Map(); + return (ip: string): boolean => { + const now = Date.now(); + const e = hits.get(ip); + if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; } + if (e.n >= max) return false; + e.n++; + return true; + }; +} + +export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: string }) { + const { uidb, ctrl } = deps; + + // ── Настройка из окружения ───────────────────────────────────────────── + const SUB_PORT = Number(process.env.SUB_PORT) || 0; + const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on"; + const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name); + + let signKey: crypto.KeyObject | null = null; + let signPub: Buffer | null = null; + try { + signKey = crypto.createPrivateKey(fs.readFileSync(file(process.env.SUB_SIGN_KEY_FILE, "sub_sign.key"))); + signPub = rawPublicKey(crypto.createPublicKey(signKey)); + } catch { + console.warn("sub: ключ подписи не найден — sen://-подписка отключена"); + } + + let tlsMaterial: { key: Buffer; cert: Buffer } | null = null; + let tlsPin: Buffer | null = null; + if (SUB_TLS) { + try { + tlsMaterial = { + key: fs.readFileSync(file(process.env.SUB_TLS_KEY_FILE, "sub_tls.key")), + cert: fs.readFileSync(file(process.env.SUB_TLS_CERT_FILE, "sub_tls.crt")), + }; + tlsPin = spkiPin(tlsMaterial.cert.toString()); + } catch { + console.warn("sub: SUB_TLS=on, но сертификат не найден — подписка отключена"); + signKey = null; + } + } + + // ── Схема ────────────────────────────────────────────────────────────── + uidb.exec(` + CREATE TABLE IF NOT EXISTS master_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + secret BLOB NOT NULL UNIQUE, + device_limit INTEGER NOT NULL DEFAULT ${DEFAULT_LIMIT}, + server_id INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + revoked_at INTEGER + ); + CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + device_name TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + pub_key TEXT NOT NULL UNIQUE, + auth_pub TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_seen INTEGER, + rekey_requested INTEGER NOT NULL DEFAULT 0, + UNIQUE (master_id, device_id) + ); + `); + // Версия клиента — необязательное поле, добавленное позже: у уже созданной + // таблицы колонки может не быть. + if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version")) + uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''"); + uidb.pragma("foreign_keys = ON"); + + const q = { + masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE revoked_at IS NULL ORDER BY id"), + masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"), + masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"), + masterInsert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO master_keys (label, secret, device_limit, created_at) VALUES (?, ?, ?, ?)"), + masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"), + masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"), + masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE id = ?"), + devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"), + deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"), + deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_id = ?"), + deviceCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM devices WHERE master_id = ?"), + deviceInsert: uidb.prepare<[number, string, string, string, string, string, number, string]>( + "INSERT INTO devices (master_id, device_id, device_name, platform, pub_key, auth_pub, created_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"), + deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"), + deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"), + deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? WHERE id = ?"), + deviceRekeyed: uidb.prepare<[string, number]>("UPDATE devices SET pub_key = ?, rekey_requested = 0 WHERE id = ?"), + deviceFlag: uidb.prepare<[number, number]>("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?"), + }; + + const now = () => Math.floor(Date.now() / 1000); + const hasCtrlOk = (s: number) => s >= 200 && s < 300; + + // ── Общие куски ──────────────────────────────────────────────────────── + async function profile() { + const r = await ctrl("GET", "/api/profile"); + if (!hasCtrlOk(r.status)) throw new Error("profile"); + return r.data as { + name: string; endpoint: string; server_pub: string; gen: string; + dns: string[]; keepalive: string; mtu: number; awg: Record; + }; + } + + const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":")); + + async function buildConfig(dev: DeviceRow) { + const [prof, peers] = await Promise.all([profile(), ctrl("GET", "/api/peers")]); + if (!hasCtrlOk(peers.status)) throw new Error("peers"); + const peer = (peers.data.peers as { pub_key: string; ip: string; psk_key: string }[]) + .find(p => p.pub_key === dev.pub_key); + if (!peer) throw new Error("peer missing"); + const cfg = { + rekey: dev.rekey_requested === 1, + endpoints: [`${hostOf(prof.endpoint)}:${SUB_PORT}`], + servers: [{ + id: 0, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub, + psk: peer.psk_key, address: `${peer.ip}/32`, dns: prof.dns, + keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg, + }], + }; + // rev меняется при любом изменении; клиент по нему решает, трогать ли туннель. + const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16); + return { rev, ...cfg }; + } + + async function dropPeer(pub: string) { + await ctrl("DELETE", `/api/peers/${pubParam(pub)}`); // 404 — уже нет, не страшно + } + + // ── Публичная часть: /sub/v1 ─────────────────────────────────────────── + const sub = express(); + sub.disable("x-powered-by"); + sub.use(express.json({ + limit: "4kb", + verify: (req, _res, buf) => { (req as any).rawBody = buf; }, + })); + + const registerLimit = makeLimiter(30, 15 * 60 * 1000); + const failLimit = makeLimiter(30, 15 * 60 * 1000); + const seenSigs = new Map(); // подпись → когда забыть + + function send(res: Response, status: number, payload: unknown) { + res.status(status).json(signResponse(payload, signKey as crypto.KeyObject)); + } + + // Проверка подписи запроса + защита от повторов. auth_pub — 32 байта. + function checkSigned(req: Request, authPub: Buffer): boolean { + const ts = Number(req.headers["x-sen-ts"]); + const sig = String(req.headers["x-sen-sig"] ?? ""); + if (!Number.isInteger(ts) || Math.abs(ts - now()) > TS_WINDOW || !sig) return false; + const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0); + if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return false; + const t = Date.now(); + for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k); + if (seenSigs.has(sig)) return false; + seenSigs.set(sig, t + 2 * TS_WINDOW * 1000); + return true; + } + + interface DevReq extends Request { device?: DeviceRow } + + function deviceAuth(req: Request, res: Response, next: NextFunction) { + const id = Number(req.headers["x-sen-device"]); + const dev = Number.isInteger(id) ? q.deviceById.get(id) as DeviceRow | undefined : undefined; + if (dev && checkSigned(req, Buffer.from(dev.auth_pub, "base64url"))) { + (req as DevReq).device = dev; + next(); + return; + } + // Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает. + if (!failLimit(req.socket.remoteAddress ?? "unknown")) { send(res, 429, { error: "rate_limited" }); return; } + send(res, 401, { error: "unauthorized" }); + } + + const wrap = (fn: (req: Request, res: Response) => Promise) => + (req: Request, res: Response) => { fn(req, res).catch(() => { send(res, 502, { error: "unavailable" }); }); }; + + sub.post("/sub/v1/register", wrap(async (req, res) => { + const ip = req.socket.remoteAddress ?? "unknown"; + if (!registerLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; } + + const b = (req.body ?? {}) as Record; + const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null; + const secretB = str(b.sub, /^[A-Za-z0-9_-]{22}$/); + const deviceId = str(b.device_id, /^[\w.-]{1,64}$/); + const pubKey = str(b.pub_key, B64_PUB); + const authPubS = str(b.auth_pub, /^[A-Za-z0-9_-]{43}$/); + const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : ""; + const platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : ""; + const version = str(b.version, CLIENT_VERSION) ?? ""; + if (!secretB || !deviceId || !pubKey || !authPubS) { send(res, 400, { error: "bad_request" }); return; } + + // Регистрация подписана ключом auth_pub из самого тела — доказательство владения. + const authPub = Buffer.from(authPubS, "base64url"); + if (authPub.length !== 32 || !checkSigned(req, authPub)) { send(res, 401, { error: "unauthorized" }); return; } + + const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined; + if (!master) { send(res, 404, { error: "not_found" }); return; } + + let deviceRowId = 0; + let oldPub: string | null = null; + try { + uidb.transaction(() => { + const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined; + if (existing) { oldPub = existing.pub_key; q.deviceDelete.run(existing.id); } + else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit) + throw Object.assign(new Error("limit"), { code: "limit" }); + deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid); + })(); + } catch (e: any) { + if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; } + send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты + } + + if (oldPub) await dropPeer(oldPub); + const r = await ctrl("POST", "/api/peers", { pub_key: pubKey, owner: `m${master.id}/d${deviceRowId}` }); + if (!hasCtrlOk(r.status)) { + q.deviceDelete.run(deviceRowId); + send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); + return; + } + const dev = q.deviceById.get(deviceRowId) as DeviceRow; + send(res, 201, { device: dev.id, config: await buildConfig(dev) }); + })); + + sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => { + const dev = (req as DevReq).device as DeviceRow; + q.deviceSeen.run(now(), dev.id); + // Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели. + const v = String(req.headers["x-sen-version"] ?? ""); + if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id); + send(res, 200, { config: await buildConfig(dev) }); + })); + + sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => { + const dev = (req as DevReq).device as DeviceRow; + const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key; + if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(res, 400, { error: "bad_request" }); return; } + const r = await ctrl("PUT", `/api/peers/${pubParam(dev.pub_key)}`, { pub_key: pub }); + if (!hasCtrlOk(r.status)) { send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); return; } + q.deviceRekeyed.run(pub, dev.id); + send(res, 200, { config: await buildConfig(q.deviceById.get(dev.id) as DeviceRow) }); + })); + + sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => { + const dev = (req as DevReq).device as DeviceRow; + await dropPeer(dev.pub_key); + q.deviceDelete.run(dev.id); + send(res, 200, { ok: true }); + })); + + sub.use((_req, res) => { send(res, 404, { error: "not_found" }); }); + // Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять. + sub.use((_err: unknown, _req: Request, res: Response, _next: NextFunction) => { send(res, 400, { error: "bad_request" }); }); + + // ── Админ-роуты панели (монтируются в server.ts за JWT) ───────────────── + const aw = (fn: (req: Request, res: Response) => Promise) => + (req: Request, res: Response) => { fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); }); }; + const idOf = (req: Request) => Number(req.params.id); + const LABEL = /^[^\r\n<>]{1,40}$/; + const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100; + + const masterKeys = Router(); + + masterKeys.get("/", (_req, res) => { + res.json({ + enabled: signKey !== null, + tls: tlsPin !== null, + keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({ + id: m.id, label: m.label, device_limit: m.device_limit, + devices: m.devices, server_id: m.server_id, created_at: m.created_at, + })), + }); + }); + + masterKeys.post("/", (req, res) => { + const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number }; + if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; } + const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit; + if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; } + const info = q.masterInsert.run(label, crypto.randomBytes(16), limit, now()); + res.status(201).json({ id: Number(info.lastInsertRowid), label, device_limit: limit, devices: 0 }); + }); + + masterKeys.patch("/:id", (req, res) => { + const m = q.masterById.get(idOf(req)) as MasterRow | undefined; + if (!m) { res.status(404).json({ error: "Не найден" }); return; } + const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number }; + const nl = label ?? m.label, nd = device_limit ?? m.device_limit; + if (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; } + if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; } + q.masterUpdate.run(nl, nd, m.id); + res.json({ id: m.id, label: nl, device_limit: nd }); + }); + + masterKeys.post("/:id/rotate", (req, res) => { + const m = q.masterById.get(idOf(req)) as MasterRow | undefined; + if (!m) { res.status(404).json({ error: "Не найден" }); return; } + q.masterSecret.run(crypto.randomBytes(16), m.id); // устройства продолжают работать: у них auth_pub + res.json({ id: m.id }); + }); + + masterKeys.post("/:id/rekey", (req, res) => { + const m = q.masterById.get(idOf(req)) as MasterRow | undefined; + if (!m) { res.status(404).json({ error: "Не найден" }); return; } + q.deviceFlag.run(-1, m.id); + res.json({ id: m.id }); + }); + + masterKeys.delete("/:id", aw(async (req, res) => { + const m = q.masterById.get(idOf(req)) as MasterRow | undefined; + if (!m) { res.status(404).json({ error: "Не найден" }); return; } + for (const d of q.devicesOf.all(m.id) as DeviceRow[]) await dropPeer(d.pub_key); + q.masterDelete.run(m.id); // devices уйдут каскадом + res.json({ success: true, id: m.id }); + })); + + masterKeys.get("/:id/link", aw(async (req, res) => { + const m = q.masterById.get(idOf(req)) as MasterRow | undefined; + if (!m) { res.status(404).json({ error: "Не найден" }); return; } + if (!signKey || !signPub || !SUB_PORT) { res.status(503).json({ error: "Подписка не настроена на сервере" }); return; } + const prof = await profile(); + const link = encodeSenLink({ + tls: tlsPin !== null, + addrs: [{ host: hostOf(prof.endpoint), port: SUB_PORT }], + secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label, + }); + res.json({ link, tls: tlsPin !== null }); + })); + + masterKeys.get("/:id/devices", aw(async (req, res) => { + const m = q.masterById.get(idOf(req)) as MasterRow | undefined; + if (!m) { res.status(404).json({ error: "Не найден" }); return; } + const stats = new Map(); + const r = await ctrl("GET", "/api/peers"); + if (hasCtrlOk(r.status)) for (const p of r.data.peers) stats.set(p.pub_key, p); + res.json({ + devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => ({ + id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at, + last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1, + ...pick(stats.get(d.pub_key)), + })), + }); + })); + + function pick(p?: { online: boolean; lastHandshake: number; rx: number; tx: number }) { + return { online: p?.online ?? false, lastHandshake: p?.lastHandshake ?? 0, rx: p?.rx ?? 0, tx: p?.tx ?? 0 }; + } + + const devices = Router(); + + devices.delete("/:id", aw(async (req, res) => { + const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined; + if (!d) { res.status(404).json({ error: "Не найдено" }); return; } + await dropPeer(d.pub_key); + q.deviceDelete.run(d.id); + res.json({ success: true, id: d.id }); + })); + + devices.post("/:id/rekey", (req, res) => { + const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined; + if (!d) { res.status(404).json({ error: "Не найдено" }); return; } + q.deviceFlag.run(d.id, -1); + res.json({ id: d.id }); + }); + + devices.post("/:id/psk", aw(async (req, res) => { + const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined; + if (!d) { res.status(404).json({ error: "Не найдено" }); return; } + const r = await ctrl("POST", `/api/peers/${pubParam(d.pub_key)}/psk`); + res.status(hasCtrlOk(r.status) ? 200 : 502).json(hasCtrlOk(r.status) ? { id: d.id } : { error: "awg-ctrl отказал" }); + })); + + // ── Листенер ─────────────────────────────────────────────────────────── + function start() { + if (!signKey) return; + if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; } + const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub); + server.listen(SUB_PORT, () => { + console.log(`sub: listening on :${SUB_PORT} (${tlsMaterial ? "https" : "http"})`); + }); + } + + return { masterKeys, devices, start }; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index c41e38f..dd8c21e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -93,6 +93,13 @@ const SERVICES = { // ui.db (ключи внешнего API) в каталоге данных AWG (вне PROJECT) — // переживает переустановку. UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db", + // Подписка sen:// (мастер-ключи SenAWG): порт, режим TLS, ключ подписи и + // сертификат. Без SUB_PORT или ключа подписи awg-ui подписку не поднимает. + SUB_PORT: process.env.SUB_PORT ?? "", + SUB_TLS: process.env.SUB_TLS ?? "off", + SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key", + SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key", + SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt", }, pidFile: "/tmp/awg-ui.pid", logFile: path.join(LOGS, "awg-ui.log"), diff --git a/install.sh b/install.sh index cf1fd3e..87ee719 100644 --- a/install.sh +++ b/install.sh @@ -52,6 +52,12 @@ UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PR # Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl. INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key" INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key" +# Подписка sen:// (SenAWG): ключ подписи ответов (Ed25519) и, при SUB_TLS=on, самоподписанный +# сертификат. В отличие от internal_auth они НЕ эфемерны — публичный ключ подписи и отпечаток +# сертификата зашиты во все выданные sen:// ссылки. +SUB_SIGN_PRIV="$AWG_DIR/sub_sign.key" +SUB_TLS_KEY="$AWG_DIR/sub_tls.key" +SUB_TLS_CRT="$AWG_DIR/sub_tls.crt" IFACE="awg1" AWG_PORT="47619" SUBNET="10.9" @@ -467,6 +473,28 @@ if [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then fi fi +# sen://-подписка (мастер-ключи для SenAWG). Порт и режим TLS зашиты в уже выданные ссылки, +# поэтому при KEEP_DATA=y берём прежние значения из существующего cli.env. +PREV_ENV="$PROJECT/cli/cli.env" +prev_val() { grep -s "^$1=" "$PREV_ENV" | tail -1 | cut -d= -f2- || true; } +SUB_PORT="${SUB_PORT:-}" +SUB_TLS="${SUB_TLS:-}" +if [[ "$KEEP_DATA" == "y" ]]; then + [[ -z "$SUB_PORT" ]] && SUB_PORT="$(prev_val SUB_PORT)" + [[ -z "$SUB_TLS" ]] && SUB_TLS="$(prev_val SUB_TLS)" +fi +if [[ -z "$SUB_PORT" ]]; then + read -rp " Sub port для sen:// (Enter — случайный): " SUB_PORT + while [[ -z "$SUB_PORT" || "$SUB_PORT" == "$UI_PORT" || "$SUB_PORT" == "$AWGCTRL_PORT" ]]; do + SUB_PORT=$(( (RANDOM % 22768) + 32768 )) + done +fi +if [[ -z "$SUB_TLS" ]]; then + read -rp " Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " ST + [[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on" +fi +[[ "$SUB_TLS" == "on" ]] || SUB_TLS="off" + # Имя сервера: если база уже существует (DB_EXISTS, проверено заранее в # preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем. if [[ "$DB_EXISTS" == "y" ]]; then @@ -483,6 +511,7 @@ echo " Server name: $SERVER_NAME" echo " AWG port: $AWG_PORT (udp)" echo " awgctrl port: $AWGCTRL_PORT" echo " UI port: $UI_PORT" +echo " Sub port: $SUB_PORT (tcp, sen://, TLS: $SUB_TLS)" echo " UI логин: $UI_USER" echo " Net interface: $NET_IFACE" if [[ "$KEEP_DATA" == "y" ]]; then @@ -789,6 +818,28 @@ fi chmod 600 "$INTERNAL_AUTH_PRIV" "$INTERNAL_AUTH_PUB" ok "Ключи внутренней авторизации awg-ui ↔ awg-ctrl" +# Подписка sen://. ⚠️ Существующие файлы при KEEP_DATA=y сохраняем: публичный ключ подписи и +# отпечаток сертификата зашиты во все выданные ссылки, новый ключ сломал бы каждую. При чистой +# установке ui.db уходит в бэкап (старые мастер-ключи всё равно недействительны) — ключи новые. +if [[ "$KEEP_DATA" == "y" && -f "$SUB_SIGN_PRIV" ]]; then + ok "Ключ подписи sen:// сохранён (существующие ссылки продолжат работать)" +else + ( umask 077; openssl genpkey -algorithm ed25519 -out "$SUB_SIGN_PRIV" ) + chmod 600 "$SUB_SIGN_PRIV" + ok "Ключ подписи sen:// создан" +fi +if [[ "$SUB_TLS" == "on" ]]; then + if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then + ok "Сертификат подписки sen:// сохранён" + else + ( 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 ) + chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT" + ok "Самоподписанный сертификат подписки sen:// создан (10 лет)" + fi +fi + # Свой файл в /etc/sysctl.d, а не /etc/sysctl.conf: в Debian 13 systemd-sysctl # /etc/sysctl.conf при загрузке больше не читает — forwarding включился бы # только до первой перезагрузки, и клиенты остались бы без интернета. @@ -953,8 +1004,9 @@ if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: act || warn "ufw route allow не сработал — трафик клиентов может резаться" ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp" ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp" + ufw allow "${SUB_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${SUB_PORT}/tcp" ufw reload >/dev/null 2>&1 || true - ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp" + ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp, ${UI_PORT}/tcp и ${SUB_PORT}/tcp" else echo " → UFW не активен, правила фаервола не трогаем" fi @@ -993,6 +1045,13 @@ UI_PORT=${UI_PORT} UI_USER=${UI_USER} UI_PASS=${UI_PASS} JWT_SECRET=${JWT_SECRET} + +# ── подписка sen:// для SenAWG (мастер-ключи) ────────────────────────────── +SUB_PORT=${SUB_PORT} +SUB_TLS=${SUB_TLS} +SUB_SIGN_KEY_FILE=${SUB_SIGN_PRIV} +SUB_TLS_KEY_FILE=${SUB_TLS_KEY} +SUB_TLS_CERT_FILE=${SUB_TLS_CRT} ENV # Имя продукта попадает в cli.env, только если оператор задал его явно @@ -1011,6 +1070,7 @@ echo echo -e " ${BLD}Сервисы:${NC}" echo " awg-ctrl → http://localhost:${AWGCTRL_PORT} (внутренний)" echo " awg-ui → http://${SERVER_IP}:${UI_PORT}" +echo " sen:// → $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} (подписка SenAWG)" echo echo -e " ${BLD}${YLW}UI логин:${NC} ${UI_USER}" echo -e " ${BLD}${YLW}UI пароль:${NC} ${UI_PASS}"