From 54ffcf9bc089d995ebde7802fd2652e0fc1a59c4 Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Fri, 10 Jul 2026 00:19:05 +0300 Subject: [PATCH] Initial commit --- .gitignore | 22 + LICENSE | 21 + awg-ctrl/index.ts | 591 ++++++++++++++++++++++ awg-ctrl/package.json | 23 + awg-ctrl/tsconfig.json | 14 + awg-ui/index.html | 12 + awg-ui/package.json | 34 ++ awg-ui/public/index.html | 1 + awg-ui/server.ts | 251 +++++++++ awg-ui/src/App.css | 698 ++++++++++++++++++++++++++ awg-ui/src/App.tsx | 259 ++++++++++ awg-ui/src/components/ServerBar.tsx | 62 +++ awg-ui/src/components/icons.tsx | 86 ++++ awg-ui/src/lib/shared.ts | 91 ++++ awg-ui/src/main.tsx | 9 + awg-ui/src/tabs/apikeys/apikeys.css | 72 +++ awg-ui/src/tabs/apikeys/index.tsx | 145 ++++++ awg-ui/src/tabs/apikeys/metadata.json | 6 + awg-ui/src/tabs/index.ts | 88 ++++ awg-ui/src/tabs/users/index.tsx | 230 +++++++++ awg-ui/src/tabs/users/metadata.json | 6 + awg-ui/src/tabs/users/users.css | 70 +++ awg-ui/src/vite-env.d.ts | 1 + awg-ui/tsconfig.json | 15 + awg-ui/vite.config.ts | 14 + cli/package.json | 12 + cli/src/index.ts | 363 ++++++++++++++ cli/tsconfig.json | 12 + install.sh | 624 +++++++++++++++++++++++ 29 files changed, 3832 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 awg-ctrl/index.ts create mode 100644 awg-ctrl/package.json create mode 100644 awg-ctrl/tsconfig.json create mode 100644 awg-ui/index.html create mode 100644 awg-ui/package.json create mode 100644 awg-ui/public/index.html create mode 100644 awg-ui/server.ts create mode 100644 awg-ui/src/App.css create mode 100644 awg-ui/src/App.tsx create mode 100644 awg-ui/src/components/ServerBar.tsx create mode 100644 awg-ui/src/components/icons.tsx create mode 100644 awg-ui/src/lib/shared.ts create mode 100644 awg-ui/src/main.tsx create mode 100644 awg-ui/src/tabs/apikeys/apikeys.css create mode 100644 awg-ui/src/tabs/apikeys/index.tsx create mode 100644 awg-ui/src/tabs/apikeys/metadata.json create mode 100644 awg-ui/src/tabs/index.ts create mode 100644 awg-ui/src/tabs/users/index.tsx create mode 100644 awg-ui/src/tabs/users/metadata.json create mode 100644 awg-ui/src/tabs/users/users.css create mode 100644 awg-ui/src/vite-env.d.ts create mode 100644 awg-ui/tsconfig.json create mode 100644 awg-ui/vite.config.ts create mode 100644 cli/package.json create mode 100644 cli/src/index.ts create mode 100644 cli/tsconfig.json create mode 100644 install.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc537f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +/tmp +/out-tsc + +**/node_modules/ +**/dist/ +cli/cli.env +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.pnp +.pnp.js + +.DS_Store +*.tar.gz + +.vscode/* +.idea/ + +# Все markdown, кроме README.md +*.md +!README.md \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..784ed72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan Vasilev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/awg-ctrl/index.ts b/awg-ctrl/index.ts new file mode 100644 index 0000000..71f79d3 --- /dev/null +++ b/awg-ctrl/index.ts @@ -0,0 +1,591 @@ +// 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 fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import path from "path"; +import crypto from "crypto"; +import { execSync, spawnSync } from "child_process"; +import * as zlib from "zlib"; +import express, { Request, Response, NextFunction } from "express"; +import Database from "better-sqlite3"; +import winston from "winston"; + +const logsDir = path.join(process.cwd(), "logs"); +if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true }); + +const logger = winston.createLogger({ + level: "debug", + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.timestamp(), + winston.format.colorize(), + winston.format.printf(({ timestamp, level, message }) => + `${timestamp} ${level}: ${message}` + ), + ), + }), + new winston.transports.File({ + filename: path.join(logsDir, "app.log"), + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json(), + ), + }), + ], +}); + +const INTERNAL_AUTH_PUB_FILE = process.env.INTERNAL_AUTH_PUB_FILE + ?? "/etc/amnezia/amneziawg/internal_auth_public.key"; +let internalAuthPubKey: crypto.KeyObject; +try { + internalAuthPubKey = crypto.createPublicKey(readFileSync(INTERNAL_AUTH_PUB_FILE)); +} catch { + logger.error("FATAL: публичный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_PUB_FILE); + process.exit(1); +} + +const SERVER = { + port: Number(process.env.PORT) || 3005, +}; + +const dbPath = path.join("/etc/amnezia/amneziawg", "users.db"); +const db = new Database(dbPath); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + name TEXT PRIMARY KEY, + ip TEXT NOT NULL UNIQUE, + pub_key TEXT NOT NULL, + vpn_key TEXT NOT NULL, + psk_key TEXT NOT NULL DEFAULT '' + ) +`); + +db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)"); + +db.exec(` + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT 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 (?, ?)"), +}; + +function getCfg(key: string, fallback: string): string { + return cfgStmts.get.get(key)?.value ?? fallback; +} + +function setCfg(key: string, value: string) { + cfgStmts.set.run(key, value); +} + +function getLocalIp(): string { + const route = spawnSync("ip", ["route", "show", "default"]); + const iface = route.stdout.toString().match(/dev\s+(\S+)/)?.[1]; + if (!iface) return ""; + const addr = spawnSync("ip", ["addr", "show", iface]); + return addr.stdout.toString().match(/inet\s+([\d.]+)/)?.[1] ?? ""; +} + +function initConfig() { + const serverIp = getCfg("serverIp", process.env.SERVER_IP ?? getLocalIp()); + const serverPort = getCfg("serverPort", process.env.SERVER_PORT ?? "51820"); + const serverName = getCfg("serverName", process.env.SERVER_NAME ?? "VPN"); + + if (!serverIp) throw new Error("SERVER_IP не задан — передай через env при первом запуске"); + + setCfg("serverIp", serverIp); + setCfg("serverPort", serverPort); + setCfg("serverName", serverName); + + return { serverIp, serverPort: Number(serverPort), serverName }; +} + +interface 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; +} + +const DEFAULT_AWG_PARAMS: AwgParams = { + Jc: 6, Jmin: 10, Jmax: 50, + S1: 90, S2: 45, S3: 37, S4: 14, + H1: "1224800044-2116730834", + H2: "2122053282-2133204808", + H3: "2133604274-2140756116", + H4: "2143656228-2147444225", + I1: "", + I2: "", I3: "", I4: "", I5: "", +}; + +function readAwgParams(): AwgParams { + const confFile = path.join("/etc/amnezia/amneziawg", "awg1.conf"); + const params: AwgParams = { ...DEFAULT_AWG_PARAMS }; + if (!existsSync(confFile)) { + logger.warn("awg1.conf не найден — параметры обфускации по умолчанию"); + return params; + } + 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"]; + + for (const k of numKeys) { + const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m")); + if (m) (params[k] as number) = Number(m[1]); + } + for (const k of strKeys) { + 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 }); + return params; +} + +const runtimeConfig = initConfig(); +const CONFIG = { + interface: "awg1", + confDir: "/etc/amnezia/amneziawg", + subnet: "10.9", + serverIp: runtimeConfig.serverIp, + serverPort: runtimeConfig.serverPort, + serverName: runtimeConfig.serverName, + dns1: "1.1.1.1", + dns2: "1.0.0.1", + mtu: 1376, + keepalive: 25, + awgParams: readAwgParams(), +}; + +interface UserRow { + name: string; + ip: string; + pub_key: string; + vpn_key: string; + psk_key: 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 (?, ?, ?, ?, ?)"), + delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"), + ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"), +}; + +function run(cmd: string): string { + return execSync(cmd, { encoding: "utf8" }).trim(); +} + +function generateKeys() { + const privateKey = run("umask 077 && awg genkey"); + const r = spawnSync("awg", ["pubkey"], { input: privateKey, encoding: "utf8" }); + if (r.status !== 0) throw new Error("awg pubkey завершился с ошибкой"); + const publicKey = (r.stdout as string).trim(); + const presharedKey = run("awg genpsk"); + return { privateKey, publicKey, presharedKey }; +} + +function getServerPublicKey(): string { + const f = path.join(CONFIG.confDir, "server_public.key"); + if (!existsSync(f)) throw new Error("server_public.key не найден"); + return readFileSync(f, "utf8").trim(); +} + +function nextIp(): string { + const usedIps = new Set(stmts.ips.all().map((r: { ip: string }) => r.ip)); + for (let c = 0; c <= 255; c++) + for (let d = 2; d <= 254; d++) { + const ip = `${CONFIG.subnet}.${c}.${d}`; + if (!usedIps.has(ip)) return ip; + } + throw new Error("Подсеть заполнена"); +} + +// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → Address → DNS +// ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом +// (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали +// ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»! +function buildClientConf( + keys: ReturnType, + ip: string, + serverPub: string, +): string { + const p = CONFIG.awgParams; + return `[Interface] +PrivateKey = ${keys.privateKey} +Jc = ${p.Jc} +Jmin = ${p.Jmin} +Jmax = ${p.Jmax} +S1 = ${p.S1} +S2 = ${p.S2} +S3 = ${p.S3} +S4 = ${p.S4} +H1 = ${p.H1} +H2 = ${p.H2} +H3 = ${p.H3} +H4 = ${p.H4} +I1 = ${p.I1} +I2 =${" "} +I3 =${" "} +I4 =${" "} +I5 =${" "} +Address = ${ip}/32 +DNS = ${CONFIG.dns1}, ${CONFIG.dns2} + +[Peer] +PublicKey = ${serverPub} +PresharedKey = ${keys.presharedKey} +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = ${CONFIG.serverIp}:${CONFIG.serverPort} +PersistentKeepalive = ${CONFIG.keepalive} +`; +} + +function encodeVpnKey( + keys: ReturnType, + ip: string, + serverPub: string, +): string { + const p = CONFIG.awgParams; + const clientConf = buildClientConf(keys, ip, serverPub); + + const lastConfigObj = { + H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4, + I1: p.I1, I2: "", I3: "", I4: "", I5: "", + Jc: String(p.Jc), + Jmax: String(p.Jmax), + Jmin: String(p.Jmin), + 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, + client_ip: ip, + client_priv_key: keys.privateKey, + client_pub_key: keys.publicKey, + config: clientConf, + hostName: CONFIG.serverIp, + mtu: String(CONFIG.mtu), + persistent_keep_alive: String(CONFIG.keepalive), + port: CONFIG.serverPort, + psk_key: keys.presharedKey, + server_pub_key: serverPub, + }; + + const json = JSON.stringify({ + containers: [{ + container: "amnezia-awg2", + awg: { + H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4, + I1: p.I1, I2: "", I3: "", I4: "", I5: "", + Jc: String(p.Jc), + Jmax: String(p.Jmax), + Jmin: String(p.Jmin), + 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", + subnet_address: `${CONFIG.subnet}.0.0`, + transport_proto: "udp", + }, + }], + defaultContainer: "amnezia-awg2", + description: CONFIG.serverName, + dns1: CONFIG.dns1, + dns2: CONFIG.dns2, + hostName: CONFIG.serverIp, + nameOverriddenByUser: true, + }); + + const jsonBuf = Buffer.from(json, "utf8"); + const compressed = zlib.deflateSync(jsonBuf); + const header = Buffer.alloc(4); + header.writeUInt32BE(jsonBuf.length, 0); + return "vpn://" + Buffer.concat([header, compressed]) + .toString("base64url") + .replace(/=+$/, ""); +} + +function getPeersData(): Record { + try { + const output = run(`awg show ${CONFIG.interface} dump`); + const result: Record = {}; + const now = Math.floor(Date.now() / 1000); + const lines = output.split("\n"); + for (let i = 1; i < lines.length; i++) { + const parts = lines[i].split("\t"); + const pubKey = parts[0]; + if (!pubKey) continue; + const lastHandshake = Number(parts[4]); + const rx = Number(parts[5]); + const tx = Number(parts[6]); + result[pubKey] = { + online: lastHandshake > 0 && (now - lastHandshake) < 180, + lastHandshake, rx, tx, + }; + } + return result; + } catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; } +} + +function rebuildConf() { + const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[]; + const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`); + if (!existsSync(confFile)) return; + + const conf = readFileSync(confFile, "utf8"); + const iface = conf.split(/^\[Peer\]/m)[0].trimEnd(); + const peers = (users as any[]).map(u => + `\n# ${u.name}\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 }); +} + +const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key"; + +const AWGQUICK_ONLY_KEY = /^\s*(Address|DNS|MTU|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)\s*=/i; + +// Готовит «stripped»-конфиг для `awg syncconf`: берёт awg1.conf, выкидывает +// awg-quick-ключи и оставляет [Interface] (PrivateKey + Jc/S/H + ListenPort) и +// [Peer]-блоки. +// 🛑 КРИТИЧНО: [Interface] с PrivateKey ОБЯЗАН попасть в этот конфиг. Раньше +// syncPeers отдавал в syncconf только [Peer]-блоки — и AmneziaWG обнулял +// приватный ключ интерфейса и параметры обфускации, после чего сервер +// поднимался с public-key=(none) и ВСЕ клиенты отваливались. +// PrivateKey подставляем из server_private.key — это та же идентичность, что в +// server_public.key (его зашивают в vpn:// ключи клиентов) и в PostUp. +function buildSyncConf(): string { + const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`); + const priv = readFileSync(SERVER_PRIV_KEY_FILE, "utf8").trim(); + const out: string[] = []; + let privReplaced = false; + for (const line of readFileSync(confFile, "utf8").split("\n")) { + if (AWGQUICK_ONLY_KEY.test(line)) continue; + if (/^\s*PrivateKey\s*=/.test(line)) { + out.push(`PrivateKey = ${priv}`); + privReplaced = true; + continue; + } + out.push(line); + } + if (!privReplaced) { + const idx = out.findIndex(l => /^\s*\[Interface\]/.test(l)); + if (idx >= 0) out.splice(idx + 1, 0, `PrivateKey = ${priv}`); + } + return out.join("\n"); +} + +function syncPeers() { + rebuildConf(); + const tmpFile = `/tmp/awg_sync_${Date.now()}.conf`; + try { + writeFileSync(tmpFile, buildSyncConf(), { mode: 0o600 }); + const r = spawnSync("awg", ["syncconf", CONFIG.interface, tmpFile]); + if (r.status !== 0) logger.warn("syncPeers syncconf failed", { stderr: r.stderr?.toString() }); + } finally { + try { fs.unlinkSync(tmpFile); } catch {} + } + const n = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n; + logger.info("peers synced", { count: n }); +} + +function getInterfaceStatus(): { up: boolean; peers: number; publicKey: string | null } { + const r = spawnSync("awg", ["show", CONFIG.interface]); + if (r.status !== 0) return { up: false, peers: 0, publicKey: null }; + const output = r.stdout.toString(); + const peers = (output.match(/^peer:/gm) ?? []).length; + const publicKey = output.match(/public key:\s*(.+)/)?.[1]?.trim() ?? null; + return { up: true, peers, publicKey }; +} + +function ensureInterfaceUp() { + const { up } = getInterfaceStatus(); + if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`); +} + +function restartAwg() { + logger.info("AWG restart: down"); + const down = spawnSync("awg-quick", ["down", CONFIG.interface]); + if (down.status !== 0) logger.warn("awg-quick down failed", { stderr: down.stderr?.toString() }); + logger.info("AWG restart: up"); + const up = spawnSync("awg-quick", ["up", CONFIG.interface]); + if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`); + syncPeers(); + logger.info("AWG restart: done"); +} + +function startInterface() { + const status = getInterfaceStatus(); + if (status.up) { logger.info("AWG already up", { peers: status.peers }); return status; } + const r = spawnSync("awg-quick", ["up", CONFIG.interface]); + if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`); + syncPeers(); + return getInterfaceStatus(); +} + +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); + 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 {} + 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 }; +} + +function removeUser(username: string) { + const user = stmts.get.get(username); + if (!user) throw new Error("Пользователь не найден"); + + stmts.delete.run(username); + spawnSync("awg", ["set", CONFIG.interface, "peer", user.pub_key, "remove"]); + rebuildConf(); + + for (const ext of [".conf", ".key"]) { + const f = path.join(CONFIG.confDir, "clients", username + ext); + if (existsSync(f)) fs.unlinkSync(f); + } + logger.info("user removed", { name: username }); +} + +const app = express(); +app.use(express.json({ limit: "1kb" })); + +function verifyInternalToken(token: string): boolean { + try { + const [h, p, s] = token.split("."); + if (!h || !p || !s) return false; + const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), internalAuthPubKey, Buffer.from(s, "base64url")); + if (!ok) return false; + const { exp } = JSON.parse(Buffer.from(p, "base64url").toString()) as { exp: number }; + return exp > Math.floor(Date.now() / 1000); + } catch { return false; } +} + +function auth(req: Request, res: Response, next: NextFunction) { + const header = (req.headers["authorization"] ?? "") as string; + const token = header.startsWith("Bearer ") ? header.slice(7) : ""; + if (!token || !verifyInternalToken(token)) { + res.status(401).json({ error: "Неверная авторизация" }); return; + } + next(); +} + +function validateName(req: Request, res: Response, next: NextFunction) { + const name = req.params.name ?? (req.body as { name?: string }).name; + if (!name || !/^[a-zA-Z0-9_-]{1,32}$/.test(name)) { + res.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" }); return; + } + next(); +} + +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" }); } + }; +} + +app.get("/health", (_req, res) => { + const { up, peers } = getInterfaceStatus(); + res.status(up ? 200 : 503).json({ + status: up ? "ok" : "degraded", + server: CONFIG.serverName, + ip: CONFIG.serverIp, + awg: { status: up ? "ok" : "down", peers }, + }); +}); + +app.post("/api/users", auth, validateName, handler((req, res) => { + const { name } = req.body as { name: string }; + if (stmts.get.get(name)) { + res.status(409).json({ error: "Пользователь уже существует" }); return; + } + res.status(201).json(addUser(name)); +})); + +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 peers = getPeersData(); + res.json({ + users: users.map(u => ({ + ...u, + online: peers[u.pub_key]?.online ?? false, + lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0, + })), + }); +})); + +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 peers = getPeersData(); + res.json({ + users: users.map(u => ({ + name: u.name, + ip: u.ip, + online: peers[u.pub_key]?.online ?? false, + lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0, + rx: peers[u.pub_key]?.rx ?? 0, + tx: peers[u.pub_key]?.tx ?? 0, + })), + }); +})); + +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; } + res.json(user); +})); + +app.delete("/api/users/:name", auth, validateName, handler((req, res) => { + removeUser(req.params.name); + res.json({ success: true, name: req.params.name }); +})); + +app.post("/awg/restart", auth, handler((_req, res) => { + restartAwg(); + res.json({ success: true }); +})); + +app.get("/awg/status", auth, handler((_req, res) => { + res.json(getInterfaceStatus()); +})); + +app.post("/awg/start", auth, handler((_req, res) => { + res.json(startInterface()); +})); + +app.listen(SERVER.port, "127.0.0.1", () => { + logger.info("Server started", { port: SERVER.port, host: "127.0.0.1", serverName: CONFIG.serverName, serverIp: CONFIG.serverIp }); +}); +ensureInterfaceUp(); +syncPeers(); diff --git a/awg-ctrl/package.json b/awg-ctrl/package.json new file mode 100644 index 0000000..79acc93 --- /dev/null +++ b/awg-ctrl/package.json @@ -0,0 +1,23 @@ +{ + "name": "awg-control", + "version": "1.0.0", + "description": "", + "main": "dist/index.js", + "scripts": { + "build": "../node_modules/.bin/tsc" + }, + "dependencies": { + "better-sqlite3": "^12.10.0", + "cors": "^2.8.6", + "express": "^4.18.0", + "winston": "^3.19.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.0", + "@types/node": "^22.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + } +} diff --git a/awg-ctrl/tsconfig.json b/awg-ctrl/tsconfig.json new file mode 100644 index 0000000..0a19e97 --- /dev/null +++ b/awg-ctrl/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": [ + "" + ] +} diff --git a/awg-ui/index.html b/awg-ui/index.html new file mode 100644 index 0000000..53a3a4a --- /dev/null +++ b/awg-ui/index.html @@ -0,0 +1,12 @@ + + + + + + AWG Panel + + +
+ + + \ No newline at end of file diff --git a/awg-ui/package.json b/awg-ui/package.json new file mode 100644 index 0000000..e0f59bb --- /dev/null +++ b/awg-ui/package.json @@ -0,0 +1,34 @@ +{ + "name": "awg-ui", + "version": "0.1.3+1", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "server": "tsx server.ts", + "mock": "tsx mock-server.ts", + "dev:mock": "concurrently -n mock,vite -c magenta,cyan \"tsx mock-server.ts\" \"vite\"" + }, + "dependencies": { + "axios": "^1.7.0", + "better-sqlite3": "^12.10.0", + "qrcode": "^1.5.0", + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "concurrently": "^9.0.0", + "@types/better-sqlite3": "^7.6.13", + "@types/qrcode": "^1.5.0", + "@types/express": "^4.17.0", + "@types/node": "^22.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "express": "^4.19.0", + "tsx": "^4.7.0", + "typescript": "^5.4.0", + "vite": "^5.4.0" + } +} \ No newline at end of file diff --git a/awg-ui/public/index.html b/awg-ui/public/index.html new file mode 100644 index 0000000..5aeba79 --- /dev/null +++ b/awg-ui/public/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/awg-ui/server.ts b/awg-ui/server.ts new file mode 100644 index 0000000..a8ad4ee --- /dev/null +++ b/awg-ui/server.ts @@ -0,0 +1,251 @@ +// 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 express, { Request, Response, NextFunction } from "express"; +import path from "path"; +import fs from "fs"; +import crypto from "crypto"; +import axios from "axios"; +import Database from "better-sqlite3"; + +const app = express(); +const PORT = Number(process.env.PORT) || 8080; + +const UI_USER = process.env.UI_USER ?? "admin"; +const UI_PASS = process.env.UI_PASS ?? ""; +const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex"); +const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`; + +const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE + ?? path.join(__dirname, "internal_auth_private.key"); +let internalAuthKey: crypto.KeyObject | null = null; +try { + internalAuthKey = crypto.createPrivateKey(fs.readFileSync(INTERNAL_AUTH_KEY_FILE)); +} catch { + console.warn("ui: приватный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_KEY_FILE + + " — запросы к awg-ctrl будут отклоняться"); +} + +function mintInternalToken(): string { + if (!internalAuthKey) throw new Error("приватный ключ внутренней авторизации не загружен"); + const now = Math.floor(Date.now() / 1000); + const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url"); + const p = Buffer.from(JSON.stringify({ iss: "awg-ui", iat: now, exp: now + 60 })).toString("base64url"); + const sig = crypto.sign(null, Buffer.from(`${h}.${p}`), internalAuthKey).toString("base64url"); + return `${h}.${p}.${sig}`; +} + +const revoked = new Set(); + +interface ApiKeyRow { + id: number; label: string; key_hash: string; prefix: string; + server_id: number; created_at: number; last_used: number | null; +} + +const UI_DB_FILE = process.env.UI_DB_FILE || path.join(__dirname, "ui.db"); +const uidb = new Database(UI_DB_FILE); +uidb.pragma("journal_mode = WAL"); +uidb.exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + server_id INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_used INTEGER + ) +`); + +const keyStmts = { + list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"), + insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"), + delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"), + byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"), + touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"), +}; + +function hashKey(key: string): string { + return crypto.createHash("sha256").update(key).digest("hex"); +} + +function genApiKey(): { key: string; hash: string; prefix: string } { + const key = "awgk_" + crypto.randomBytes(24).toString("base64url"); + return { key, hash: hashKey(key), prefix: key.slice(0, 13) + "…" }; +} + +function tokenSig(token: string): string { return token.split(".")[2] ?? token; } + +function sign(): string { + const h = Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url"); + const p = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86400 })).toString("base64url"); + const s = crypto.createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url"); + return `${h}.${p}.${s}`; +} + +function verify(token: string): boolean { + try { + if (revoked.has(tokenSig(token))) return false; + const [h, p, s] = token.split("."); + if (!h || !p || !s) return false; + const expected = crypto.createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest(); + const actual = Buffer.from(s, "base64url"); + if (actual.length !== expected.length) return false; + if (!crypto.timingSafeEqual(actual, expected)) return false; + const { exp } = JSON.parse(Buffer.from(p, "base64url").toString()) as { exp: number }; + return exp > Math.floor(Date.now() / 1000); + } catch { return false; } +} + +function requireAuth(req: Request, res: Response, next: NextFunction) { + const auth = (req.headers["authorization"] ?? "") as string; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + if (!token || !verify(token)) { res.status(401).json({ error: "Unauthorized" }); return; } + next(); +} + +const RATE_LIMIT = 5; +const RATE_WINDOW = 15 * 60 * 1000; + +interface RateEntry { count: number; resetAt: number; } +const loginAttempts = new Map(); + +function checkRate(ip: string): { ok: boolean; retryAfter?: number } { + const now = Date.now(); + const entry = loginAttempts.get(ip); + if (!entry || entry.resetAt < now) { + loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW }); + return { ok: true }; + } + if (entry.count >= RATE_LIMIT) { + return { ok: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) }; + } + entry.count++; + return { ok: true }; +} + +app.use(express.json()); + +app.post("/login", (req: Request, res: Response) => { + const ip = req.socket.remoteAddress ?? "unknown"; + const { ok, retryAfter } = checkRate(ip); + if (!ok) { + res.status(429).json({ error: `Слишком много попыток. Повтори через ${retryAfter} сек.` }); return; + } + const { user, pass } = req.body as { user?: string; pass?: string }; + if (!UI_PASS || user !== UI_USER || pass !== UI_PASS) { + res.status(401).json({ error: "Неверный логин или пароль" }); return; + } + loginAttempts.delete(ip); + res.json({ token: sign() }); +}); + +app.post("/logout", requireAuth, (req: Request, res: Response) => { + const token = (req.headers["authorization"] as string).slice(7); + revoked.add(tokenSig(token)); + res.json({ ok: true }); +}); + +app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => { + res.json({ keys: keyStmts.list.all() }); +}); + +app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => { + const { label, server_id } = req.body as { label?: string; server_id?: number }; + if (!label || !/^[\w \-]{1,40}$/.test(label)) { + res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return; + } + // TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится + // список серверов — валидировать его против реального реестра серверов. + const { key, hash, prefix } = genApiKey(); + const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000)); + res.status(201).json({ id: info.lastInsertRowid, label, prefix, key }); +}); + +app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; } + keyStmts.delete.run(id); + res.json({ success: true, id }); +}); + +async function proxy(req: Request, res: Response) { + try { + const r = await axios({ + method: req.method, + url: CTRL + req.originalUrl, + headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" }, + data: req.body, + validateStatus: () => true, + }); + res.status(r.status).json(r.data); + } catch { + res.status(502).json({ error: "awg-ctrl недоступен" }); + } +} + +// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий +// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный +// awg-ctrl по (req as ExtRequest).apiKey.server_id. + +interface ExtRequest extends Request { apiKey?: ApiKeyRow; } + +function requireApiKey(req: Request, res: Response, next: NextFunction) { + const key = (req.headers["x-api-key"] as string) ?? ""; + if (!key.startsWith("awgk_")) { res.status(401).json({ error: "API key required" }); return; } + const row = keyStmts.byHash.get(hashKey(key)) as ApiKeyRow | undefined; + if (!row) { res.status(401).json({ error: "Invalid API key" }); return; } + keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id); + (req as ExtRequest).apiKey = row; + next(); +} + +async function ctrl(method: string, urlPath: string, body?: unknown) { + return axios({ + method, + url: CTRL + urlPath, + headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" }, + data: body, + validateStatus: () => true, + }); +} + +const ext = express.Router(); +ext.use(requireApiKey); + +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 }); +}); + +ext.get("/users", async (_req: Request, res: Response) => { + const r = await ctrl("GET", "/api/users/stats"); + res.status(r.status).json(r.data); +}); + +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 }); +}); + +ext.delete("/users/:name", async (req: Request, res: Response) => { + const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`); + res.status(r.status).json(r.data); +}); + +app.use("/api/v1", ext); + +app.use(["/api", "/health", "/awg"], requireAuth, proxy); + +app.use(express.static(path.join(__dirname, "dist"))); + +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 diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css new file mode 100644 index 0000000..443b04e --- /dev/null +++ b/awg-ui/src/App.css @@ -0,0 +1,698 @@ +/* +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. +*/ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body, #root { height: 100%; } + +:root { + --primary: #6a45b8; + --on-primary: #ffffff; + --primary-container: #ebddff; + --on-primary-container: #5a2da8; + + --surface: #faf6f0; + --surface-bright: #fdfaf6; + --surface-container: #f0e8dd; + --surface-hi: #ece2d3; + --on-surface: #3a2e22; + + --outline: #d8cdbe; + --outline-variant: #ece2d3; + + --success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922; + --neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85; + --error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a; + + --danger-border: #e6c4bc; + --danger-text: #b34a3a; + + /* приглушённый «бровый» лейбл — заголовки таблицы, неактивная навигация */ + --label: #5a4a3a; + + /* state layers / тени — зависят от темы */ + --state-primary: rgba(106, 69, 184, .10); + --state-row: rgba(106, 69, 184, .035); + --state-neutral: rgba(58, 46, 34, .08); + --scrim: rgba(58, 46, 34, .45); + --shadow: rgba(58, 46, 34, .15); + + --font: system-ui, -apple-system, 'Inter', sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + + --r-pill: 100px; + --r-card: 24px; + --r-table: 16px; + --r-logo: 9px; +} + +:root[data-theme="dark"] { + --primary: #cbb3ff; + --on-primary: #381b6c; + --primary-container: #52389e; + --on-primary-container: #ebddff; + + --surface: #1a1611; + --surface-bright: #2b251d; + --surface-container: #251f18; + --surface-hi: #302820; + --on-surface: #ede2d4; + + --outline: #4d4334; + --outline-variant: #342c20; + + --success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f; + --neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085; + --error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449; + + --danger-border: #6e4540; + --danger-text: #ffb4ab; + + --label: #b6a994; + + --state-primary: rgba(203, 179, 255, .14); + --state-row: rgba(203, 179, 255, .05); + --state-neutral: rgba(237, 226, 212, .08); + --scrim: rgba(0, 0, 0, .55); + --shadow: rgba(0, 0, 0, .45); +} + +body { + font-family: var(--font); + font-size: 14px; + font-weight: 400; + color: var(--on-surface); + background: var(--surface); + -webkit-font-smoothing: antialiased; + transition: background 0.2s ease, color 0.2s ease; +} + +.app { min-height: 100vh; display: flex; flex-direction: column; position: relative; } + +.logo-name { font-size: 17px; font-weight: 500; color: var(--on-surface); } + +.login-screen { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface); +} +.login-card { + width: 352px; + background: var(--surface-bright); + border-radius: var(--r-card); + border: 1px solid var(--outline-variant); + padding: 36px 32px 32px; + display: flex; + flex-direction: column; + gap: 12px; +} +.login-sub { + font-size: 14px; + color: var(--neutral-text); + margin-bottom: 4px; +} +.login-error { + font-size: 13px; + color: var(--danger-text); + text-align: center; +} + +.field { + height: 44px; + padding: 0 16px; + border: 1.5px solid var(--outline); + border-radius: var(--r-pill); + background: var(--surface-bright); + font-family: var(--font); + font-size: 14px; + color: var(--on-surface); + outline: none; + transition: border-color 0.15s; + width: 100%; +} +.field:focus { border-color: var(--primary); } +.field::placeholder { color: var(--neutral-dot); } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 44px; + padding: 0 20px; + border-radius: var(--r-pill); + border: none; + font-family: var(--font); + font-size: 14px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.12s, background 0.12s; + flex-shrink: 0; +} +.btn:hover { opacity: 0.88; } +.btn:active { opacity: 0.74; } + +.btn--primary { background: var(--primary); color: var(--on-primary); } +.btn--tonal { background: var(--primary-container); color: var(--on-primary-container); border: none; } +.btn--outline { + background: transparent; + border: 1.5px solid var(--outline); + color: var(--primary); +} +.btn--danger { + background: transparent; + border: 1.5px solid var(--danger-border); + color: var(--danger-text); +} +.btn--full { width: 100%; } +.btn--sq { width: 44px; padding: 0; flex-shrink: 0; } + +/* icon-only circular button */ +.btn-icon { + width: 30px; height: 30px; + border-radius: 50%; + border: none; + background: transparent; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--primary); + transition: background 0.12s; + flex-shrink: 0; +} +.btn-icon:hover { background: var(--state-primary); } +.btn-icon--danger { color: var(--danger-text); } +.btn-icon--danger:hover { background: rgba(179,74,58,.10); } +.btn-icon--neutral { color: var(--neutral-text); } +.btn-icon--neutral:hover { background: var(--state-neutral); } + +.theme-toggle { + position: fixed; + top: 16px; + right: 16px; + z-index: 250; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1.5px solid var(--outline); + background: var(--surface-bright); + color: var(--on-surface); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.theme-toggle:hover { border-color: var(--primary); color: var(--primary); } + +/* Перезапуск AWG — pill-кнопка с подписью в правом верхнем углу, слева от темы */ +.awg-restart { + position: fixed; + top: 16px; + right: 64px; + z-index: 250; + height: 40px; + padding: 0 16px; + gap: 6px; + border-radius: 100px; + border: 1.5px solid var(--outline); + background: var(--surface-bright); + color: var(--on-surface); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 500; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.awg-restart:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); } +.awg-restart:disabled { opacity: 0.5; cursor: default; } +.awg-restart.spinning svg { animation: awg-spin 0.8s linear infinite; } +@keyframes awg-spin { to { transform: rotate(360deg); } } + +/* Мобильная кнопка перезапуска (в сервер-баре) — на десктопе скрыта */ +.awg-restart-mobile { display: none; } +.awg-restart-mobile.spinning svg { animation: awg-spin 0.8s linear infinite; } + +.top-bar { + display: none; + align-items: center; + gap: 4px; + height: 56px; + padding: 0 8px 0 4px; + background: var(--surface); + flex-shrink: 0; + z-index: 10; +} +.hamburger-btn { + width: 48px; height: 48px; + border: none; + background: transparent; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--on-surface); + flex-shrink: 0; + -webkit-tap-highlight-color: transparent; +} +.hamburger-btn:active { background: var(--state-neutral); } +.top-bar-title { + font-size: 20px; + font-weight: 500; + color: var(--on-surface); +} + +.drawer-scrim { + display: none; + position: fixed; + inset: 0; + background: var(--scrim); + z-index: 300; + opacity: 0; + pointer-events: none; + transition: opacity 0.27s cubic-bezier(0.2, 0, 0, 1); +} +.drawer-scrim--visible { + opacity: 1; + pointer-events: auto; +} + +.layout { + flex: 1; + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 240px; + flex-shrink: 0; + background: var(--surface-container); + display: flex; + flex-direction: column; + padding: 24px 12px; + gap: 0; +} +.sidebar-logo { + padding: 0 8px 20px; + display: flex; + flex-direction: column; + gap: 2px; +} +.drawer-divider { + border: none; + border-top: 0.5px solid var(--outline); + margin: 0 -12px 8px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--r-pill); + font-size: 14px; + font-weight: 400; + color: var(--label); + cursor: default; + user-select: none; +} +.nav-item--active { + background: var(--primary-container); + color: var(--on-primary-container); + font-weight: 500; +} + +.sidebar-spacer { flex: 1; } + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--r-pill); + font-size: 12px; + white-space: nowrap; +} +.chip-dot { + width: 6px; height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.chip--online { background: var(--success-bg); color: var(--success-text); } +.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); } + +.main { + flex: 1; + padding: 32px 36px; + display: flex; + flex-direction: column; + gap: 20px; + overflow: auto; + min-width: 0; +} +.page-title { + font-size: 20px; + font-weight: 500; + color: var(--on-surface); +} + +.server-bar { + display: flex; + align-items: center; + gap: 8px; +} +.server-card { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px 8px 10px; + border-radius: var(--r-card); + border: 1.5px solid var(--outline-variant); + background: var(--surface-bright); + cursor: default; + user-select: none; +} +.server-card--active { border-color: var(--primary-container); } +.server-card-info { + display: flex; + flex-direction: column; + gap: 1px; + flex: 1; + min-width: 0; +} +.server-card-name { + font-size: 14px; + font-weight: 500; + color: var(--on-surface); + line-height: 1.2; +} +.server-card-ip { + font-family: var(--mono); + font-size: 11px; + color: var(--neutral-text); + line-height: 1.2; +} +.server-card-peers { + font-size: 12px; + color: var(--neutral-text); + background: var(--surface-container); + padding: 2px 8px; + border-radius: var(--r-pill); + white-space: nowrap; + flex-shrink: 0; +} + +/* Переключатель сервера — скрыт на десктопе, показывается на мобильном */ +.server-toggle { + display: none; + position: relative; + width: 36px; height: 20px; + background: var(--neutral-bg); + border-radius: var(--r-pill); + flex-shrink: 0; + transition: background 0.2s; +} +.server-toggle--on { background: var(--success-bg); } +.server-toggle-thumb { + position: absolute; + top: 3px; left: 3px; + width: 14px; height: 14px; + background: var(--neutral-dot); + border-radius: 50%; + transition: transform 0.2s, background 0.2s; +} +.server-toggle--on .server-toggle-thumb { + transform: translateX(16px); + background: var(--success-dot); +} + +/* Чип онлайн/офлайн на карточке сервера — виден на десктопе */ +.server-online-chip { display: inline-flex; } + +/* Tooltip */ +.tip-wrap { + position: relative; + display: inline-flex; +} +.tip-wrap::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 7px); + left: 50%; + transform: translateX(-50%); + background: var(--on-surface); + color: var(--surface-bright); + font-size: 12px; + white-space: nowrap; + padding: 5px 12px; + border-radius: var(--r-pill); + pointer-events: none; + opacity: 0; + transition: opacity 0.12s; +} +.tip-wrap:hover::after { opacity: 1; } + +.server-add { + display: inline-flex; + align-items: center; + gap: 6px; + height: 44px; + padding: 0 16px; + border-radius: var(--r-pill); + border: 1.5px dashed var(--outline); + background: transparent; + font-family: var(--font); + font-size: 13px; + color: var(--neutral-dot); + cursor: not-allowed; + white-space: nowrap; +} + +.toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.toolbar .field { + width: 224px; + height: 44px; + flex-shrink: 0; +} +/* На десктопе кнопки участвуют в flex toolbar'а напрямую */ +.toolbar-btns { display: contents; } + +.table-card { + background: var(--surface-bright); + border-radius: var(--r-table); + border: 1px solid var(--outline-variant); + overflow: hidden; +} +table { + width: 100%; + border-collapse: collapse; +} +thead th { + background: var(--surface-container); + color: var(--label); + font-weight: 500; + font-size: 13px; + text-align: left; + padding: 11px 14px; +} +thead th:first-child { padding-left: 20px; } +thead th:last-child { padding-right: 20px; } + +tbody tr { border-top: 0.5px solid var(--outline-variant); } +tbody tr:hover { background: var(--state-row); } + +tbody td { + padding: 11px 14px; + font-size: 13px; + color: var(--on-surface); + vertical-align: middle; +} +tbody td:first-child { padding-left: 20px; } +tbody td:last-child { padding-right: 20px; } + +.td-num { font-size: 12px; color: var(--neutral-dot); width: 32px; } +.td-mono { font-family: var(--mono); font-size: 12px; } +.td-actions { width: 96px; } + +.actions { display: flex; align-items: center; gap: 2px; } + +.empty { + text-align: center; + color: var(--neutral-text); + padding: 48px 0 !important; + font-size: 14px; +} + +/* Карточки/метрики вкладки «Пользователи» → tabs/users/users.css */ + +@keyframes backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes card-in { + from { opacity: 0; transform: scale(.92) translateY(12px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.qr-backdrop { + position: fixed; inset: 0; + background: var(--scrim); + display: flex; align-items: center; justify-content: center; + z-index: 300; + animation: backdrop-in 0.18s ease; +} +.qr-card { + background: var(--surface-bright); + border-radius: var(--r-card); + border: 1px solid var(--outline-variant); + padding: 28px 28px 24px; + display: flex; flex-direction: column; align-items: center; gap: 14px; + width: 320px; + animation: card-in 0.22s cubic-bezier(.34,1.26,.64,1); +} +.qr-name { font-size: 16px; font-weight: 500; color: var(--on-surface); } +.qr-img { + border-radius: 12px; + cursor: pointer; + transition: opacity 0.2s ease, box-shadow 0.35s ease; + box-shadow: 0 0 0 0px transparent; + width: 280px; height: 280px; + image-rendering: crisp-edges; +} +.qr-img:hover { opacity: 0.88; } +.qr-img:active { + opacity: 1; + box-shadow: 0 0 0 3px var(--primary-container), 0 0 0 5.5px var(--primary); + transition: opacity 0.1s ease, box-shadow 0.15s ease; +} +.qr-hint { font-size: 12px; color: var(--neutral-text); text-align: center; } +.qr-actions { display: flex; gap: 8px; width: 100%; } +.qr-actions .btn { flex: 1; } + +/* Открытый API-ключ → tabs/apikeys/apikeys.css */ + +.snack { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + background: var(--on-surface); + color: var(--surface-bright); + font-size: 13px; + padding: 10px 22px; + border-radius: var(--r-pill); + z-index: 500; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 4px 16px var(--shadow); +} + +.sidebar-version { + font-size: 11px; + color: var(--neutral-dot); + margin-top: 2px; +} + +@media (max-width: 640px) { + /* Top bar */ + .top-bar { display: flex; } + .page-title { display: none; } + + /* Drawer scrim */ + .drawer-scrim { display: block; } + + /* Запретить горизонтальный скролл страницы */ + body, .app { overflow-x: hidden; } + + /* Sidebar → модальный drawer */ + .layout { + min-height: 0; /* flex: 1 на .app справится сам */ + overflow: visible; + } + /* main — не скролл-контейнер, пусть скроллит body */ + .main { overflow: visible; } + + .sidebar { + position: fixed; + top: 0; left: 0; + height: 100%; + width: 280px; + z-index: 400; + border-radius: 0 var(--r-card) var(--r-card) 0; + transform: translateX(-100%); + transition: transform 0.27s cubic-bezier(0.2, 0, 0, 1); + box-shadow: 4px 0 24px var(--shadow); + padding-top: 36px; + } + .sidebar--open { transform: translateX(0); } + + /* Main: одна колонка, поля 16px */ + .main { + padding: 16px; + gap: 12px; + } + + /* Server bar → вертикально */ + .server-bar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + .server-card { + width: 100%; + border-width: 2px; + border-color: var(--primary-container); + padding: 12px 14px; + } + .server-toggle { display: flex; } + .server-online-chip { display: none; } + + /* Кнопка «Добавить сервер» — полная ширина */ + .tip-wrap { width: 100%; } + .server-add { width: 100%; justify-content: center; } + + /* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */ + .awg-restart { display: none; } + .awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; } + + /* Toolbar → вертикально */ + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + .toolbar .field { width: 100%; } + .toolbar-btns { + display: flex; + gap: 8px; + } + .toolbar-btns .btn { flex: 1; } + + /* Таблица скрывается; карточки показываются в tabs/users/users.css */ + .table-card { display: none; } + + /* QR модалка — по ширине экрана */ + .qr-card { + width: calc(100vw - 32px); + max-width: 360px; + padding: 24px 20px 20px; + } + .qr-img { width: 100%; height: auto; aspect-ratio: 1; } +} diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx new file mode 100644 index 0000000..eb95585 --- /dev/null +++ b/awg-ui/src/App.tsx @@ -0,0 +1,259 @@ +// 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 axios from 'axios'; +import './App.css'; +import { TOKEN_KEY, THEME_KEY, apiFetch, type ServerInfo } from './lib/shared'; +import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons'; +import ServerBar from './components/ServerBar'; +import { TABS } from './tabs'; + +// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token, +// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит +// из реестра TABS — App про конкретные вкладки ничего не знает. +export default function App() { + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? ''); + const [loginUser, setLoginUser] = useState(''); + const [loginPass, setLoginPass] = useState(''); + const [statusText, setStatusText] = useState(''); + const [serverInfo, setServerInfo] = useState(null); + const [msg, setMsg] = useState(''); + const [drawerOpen, setDrawerOpen] = useState(false); + const [activeTab, setActiveTab] = useState(TABS[0].id); + const [restarting, setRestarting] = useState(false); + const [theme, setTheme] = useState<'light' | 'dark'>(() => + localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'); + const touchStartX = useRef(0); + const touchCurrentX = useRef(0); + + const showMsg = useCallback((text: string) => { + setMsg(text); + setTimeout(() => setMsg(''), 3000); + }, []); + + // Проверяет токен и подтягивает данные сервера (/health). Данные конкретных + // вкладок (юзеры, ключи) грузят сами вкладки. + 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 }); + setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?')); + }, []); + + const login = useCallback(async () => { + try { + const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass }); + const tok = auth.token as string; + await startSession(tok); + localStorage.setItem(TOKEN_KEY, tok); + setToken(tok); + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 401) { + showMsg('Неверный логин или пароль'); + } else if (axios.isAxiosError(e) && e.response?.status === 429) { + showMsg(e.response.data?.error ?? 'Слишком много попыток'); + } else { + setStatusText('offline:Недоступен'); + } + } + }, [loginUser, loginPass, startSession, showMsg]); + + const logout = useCallback(async () => { + try { + await axios.post('/logout', null, { headers: { Authorization: `Bearer ${token}` } }); + } catch { /* токен уже мог истечь */ } + localStorage.removeItem(TOKEN_KEY); + setToken(''); + setStatusText(''); + setServerInfo(null); + setDrawerOpen(false); + }, [token]); + + // Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl). + // Соединения клиентов кратковременно прерываются — поэтому подтверждение. + const restartAwg = useCallback(async () => { + if (restarting) return; + if (!confirm('Перезапустить AWG? Соединения клиентов кратковременно прервутся.')) return; + setRestarting(true); + try { + await apiFetch('POST', '/awg/restart', token); + showMsg('AWG перезапущен'); + } catch { + showMsg('Не удалось перезапустить AWG'); + } finally { + setRestarting(false); + } + }, [restarting, token, showMsg]); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + touchStartX.current = e.touches[0].clientX; + touchCurrentX.current = e.touches[0].clientX; + }, []); + const handleTouchMove = useCallback((e: React.TouchEvent) => { + touchCurrentX.current = e.touches[0].clientX; + }, []); + const handleTouchEnd = useCallback(() => { + if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false); + }, []); + + // Восстановить сессию из сохранённого токена + useEffect(() => { + const saved = localStorage.getItem(TOKEN_KEY); + if (saved) { + startSession(saved).catch(() => { + localStorage.removeItem(TOKEN_KEY); + setToken(''); + }); + } + }, [startSession]); + + // Применять и сохранять тему + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(THEME_KEY, theme); + }, [theme]); + + // Блокировать прокрутку фона когда drawer открыт + useEffect(() => { + document.body.style.overflow = drawerOpen ? 'hidden' : ''; + return () => { document.body.style.overflow = ''; }; + }, [drawerOpen]); + + // Закрывать drawer по Escape + useEffect(() => { + if (!drawerOpen) return; + const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); }; + document.addEventListener('keydown', h); + return () => document.removeEventListener('keydown', h); + }, [drawerOpen]); + + const isOnline = statusText.startsWith('online:'); + const statusLabel = statusText.replace(/^(online|offline):/, ''); + const tab = TABS.find(t => t.id === activeTab) ?? TABS[0]; + // Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён. + const chrome = tab.chrome ?? {}; + const showServerBar = chrome.serverBar !== false; + const showRestart = chrome.restart !== false; + + return ( +
+ + + {token && showRestart && ( + + )} + + {!token ? ( +
+
+ Forgetting +

Войдите, чтобы продолжить

+ setLoginUser(e.target.value)} + autoFocus + /> + setLoginPass(e.target.value)} + onKeyDown={e => e.key === 'Enter' && login()} + /> + + {statusText.startsWith('offline:') && ( +

{statusLabel}

+ )} +
+
+ ) : ( + <> + {/* Top bar — видим только на мобильном */} +
+ + {tab.label} +
+ + {/* Затемнение под drawer */} +
setDrawerOpen(false)} + /> + +
+ + +
+

{tab.label}

+ {/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */} + {showServerBar && ( + + )} + +
+
+ + )} + + {msg &&
{msg}
} +
+ ); +} \ No newline at end of file diff --git a/awg-ui/src/components/ServerBar.tsx b/awg-ui/src/components/ServerBar.tsx new file mode 100644 index 0000000..96fb1c2 --- /dev/null +++ b/awg-ui/src/components/ServerBar.tsx @@ -0,0 +1,62 @@ + +import { type ServerInfo } from '../lib/shared'; +import { IcoPlus, IcoRefresh } from './icons'; + +// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной) +// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает) +// сервер, к которому относится содержимое вкладки (пиры / API-ключи). +// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов +// станет несколько — карточки станут кликабельными, активная = выбранная. +export default function ServerBar({ + serverInfo, serverOnline, onRestartAwg, restarting, + showAddServer = true, showRestart = true, +}: { + serverInfo: ServerInfo | null; + serverOnline: boolean; + onRestartAwg: () => void; + restarting: boolean; + showAddServer?: boolean; + showRestart?: boolean; +}) { + return ( +
+ {serverInfo && ( +
+ {/* toggle — виден на мобильном вместо чипа */} + + )} + {showAddServer && ( + + + + )} + {/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */} + {showRestart && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/awg-ui/src/components/icons.tsx b/awg-ui/src/components/icons.tsx new file mode 100644 index 0000000..f792258 --- /dev/null +++ b/awg-ui/src/components/icons.tsx @@ -0,0 +1,86 @@ +// Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда +// её иконку и зарегистрируй имя в ICONS (его указывают в tabs//metadata.json). + +export const IcoMenu = () => ( + + + + + +); +export const IcoUsers = () => ( + + + + + +); +export const IcoKey = () => ( + + + + +); +export const IcoPlus = () => ( + + + +); +export const IcoRefresh = () => ( + + + + +); +export const IcoQR = () => ( + + + + + +); +export const IcoTrash = () => ( + + + + + +); +export const IcoLogout = () => ( + + + + + +); +export const IcoGlobe = () => ( + + + + + +); +export const IcoCopy = () => ( + + + + +); +export const IcoSun = () => ( + + + + +); +export const IcoMoon = () => ( + + + +); + +// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр +// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку. +export const ICONS: Record JSX.Element> = { + users: IcoUsers, + key: IcoKey, +}; \ No newline at end of file diff --git a/awg-ui/src/lib/shared.ts b/awg-ui/src/lib/shared.ts new file mode 100644 index 0000000..bc063e4 --- /dev/null +++ b/awg-ui/src/lib/shared.ts @@ -0,0 +1,91 @@ +// Общие типы, константы и утилиты для всех вкладок панели. +import axios from 'axios'; +export interface User { + name: string; + ip: string; + pub_key: string; + vpn_key: string; + online: boolean; + lastHandshake: number; + rx?: number; + tx?: number; +} +export interface ServerInfo { + name: string; + ip: string; + peers: number; +} +export interface ApiKey { + id: number; + label: string; + prefix: string; + server_id: number; + created_at: number; + last_used: number | null; +} +export interface PageProps { + token: string; + showMsg: (text: string) => void; +} +export const TOKEN_KEY = 'awg_token'; +export const THEME_KEY = 'awg_theme'; +export async function apiFetch(method: string, path: string, token: string, body?: object): Promise { + const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body }); + return r.data; +} + +export async function vpnKeyToConf(vpnKey: string): Promise { + const b64 = vpnKey.replace('vpn://', '').replace(/-/g, '+').replace(/_/g, '/'); + const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); + const stream = new Blob([raw.slice(4)]).stream().pipeThrough(new DecompressionStream('deflate')); + const json = await new Response(stream).text(); + const data = JSON.parse(json); + const awg = data.containers[0].awg; + try { + const lc = JSON.parse(awg.last_config || '{}'); + return (lc.config || '') + .replace('$PRIMARY_DNS', data.dns1 || '1.1.1.1') + .replace('$SECONDARY_DNS', data.dns2 || '1.0.0.1'); + } catch { + return (awg.last_config || '') + .replace('$PRIMARY_DNS', data.dns1 || '1.1.1.1') + .replace('$SECONDARY_DNS', data.dns2 || '1.0.0.1'); + } +} +export function downloadFile(filename: string, text: string) { + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} +export async function copyText(text: string) { + if (navigator.clipboard) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;opacity:0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } +} +export function bytes(n: number | undefined): string { + if (!n) return '—'; + if (n < 1024) return n + ' B'; + if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB'; + return (n / 1_073_741_824).toFixed(2) + ' GB'; +} +export function timeAgo(ts: number | undefined): string { + if (!ts) return '—'; + const sec = Math.floor(Date.now() / 1000) - ts; + if (sec < 60) return sec + ' сек'; + if (sec < 3600) return Math.floor(sec / 60) + ' мин'; + return Math.floor(sec / 3600) + ' ч'; +} \ No newline at end of file diff --git a/awg-ui/src/main.tsx b/awg-ui/src/main.tsx new file mode 100644 index 0000000..e2a6eb0 --- /dev/null +++ b/awg-ui/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + +); \ No newline at end of file diff --git a/awg-ui/src/tabs/apikeys/apikeys.css b/awg-ui/src/tabs/apikeys/apikeys.css new file mode 100644 index 0000000..d8ec725 --- /dev/null +++ b/awg-ui/src/tabs/apikeys/apikeys.css @@ -0,0 +1,72 @@ +/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */ + +/* Открытый API-ключ (показывается один раз при создании) */ +.apikey-value { + font-family: var(--mono); + font-size: 13px; + color: var(--on-surface); + background: var(--surface-container); + border: 1px solid var(--outline-variant); + border-radius: 12px; + padding: 12px 14px; + width: 100%; + word-break: break-all; + user-select: all; +} + +.apikey-cards-list { display: none; flex-direction: column; gap: 12px; } + +.apikey-count { font-size: 12px; color: var(--neutral-text); } +.apikey-empty { + font-size: 14px; + color: var(--neutral-text); + text-align: center; + padding: 48px 0; +} +.apikey-card { + background: var(--surface-bright); + border: 1px solid var(--outline-variant); + border-radius: var(--r-card); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.apikey-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.apikey-card-title { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} +.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; } +.apikey-card-label { + font-size: 16px; + font-weight: 500; + color: var(--on-surface); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.apikey-card-prefix { + font-family: var(--mono); + font-size: 13px; + color: var(--neutral-text); + word-break: break-all; +} +.apikey-card-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--neutral-text); +} + +@media (max-width: 640px) { + .apikey-cards-list { display: flex; } +} diff --git a/awg-ui/src/tabs/apikeys/index.tsx b/awg-ui/src/tabs/apikeys/index.tsx new file mode 100644 index 0000000..640db77 --- /dev/null +++ b/awg-ui/src/tabs/apikeys/index.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect, useCallback } from 'react'; +import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared'; +import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons'; +import './apikeys.css'; + +// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается +// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App. +// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список +// серверов — ключ будет привязываться к выбранному в сервер-баре. +export default function ApiKeysPage({ token, showMsg }: PageProps) { + const [keys, setKeys] = useState([]); + const [label, setLabel] = useState(''); + const [created, setCreated] = useState<{ label: string; key: string } | null>(null); + + const load = useCallback(async () => { + try { + const data = await apiFetch('GET', '/ui/apikeys', token); + setKeys(data.keys ?? []); + } catch { + showMsg('Ошибка загрузки ключей'); + } + }, [token, showMsg]); + + const createKey = useCallback(async () => { + if (!label.trim()) return; + const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 }); + if (r.error) { showMsg(r.error); return; } + setLabel(''); + setCreated({ label: r.label, key: r.key }); + await load(); + }, [label, token, load, showMsg]); + + const deleteKey = useCallback(async (id: number, lbl: string) => { + if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return; + await apiFetch('DELETE', '/ui/apikeys/' + id, token); + showMsg('Ключ удалён: ' + lbl); + await load(); + }, [token, load, showMsg]); + + useEffect(() => { if (token) load(); }, [token, load]); + + return ( + <> + {/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ. + TODO multi-server: server_id берётся из выбранной карточки (пока 0). */} +
+ setLabel(e.target.value)} + onKeyDown={e => e.key === 'Enter' && createKey()} + /> +
+ +
+
+ +
+ + + + + + + + + + + + + {keys.length === 0 ? ( + + ) : keys.map((k, i) => ( + + + + + + + + + ))} + +
#МеткаКлючСозданПоследний раз
Нет ключей
{i + 1}{k.label}{k.prefix}{new Date(k.created_at * 1000).toLocaleDateString()}{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'} +
+ +
+
+
+ + {/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */} +
+

{keys.length} ключей

+ {keys.length === 0 ? ( +

Нет ключей

+ ) : keys.map((k, i) => ( +
+
+
+ #{i + 1} + {k.label} +
+ +
+ {k.prefix} +
+ Создан {new Date(k.created_at * 1000).toLocaleDateString()} + {k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'} +
+
+ ))} +
+ + {created && ( +
setCreated(null)}> +
e.stopPropagation()}> +

Ключ «{created.label}» создан

+

+ Скопируй ключ сейчас — он показывается один раз и больше не будет доступен. +

+ {created.key} +
+ + +
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/awg-ui/src/tabs/apikeys/metadata.json b/awg-ui/src/tabs/apikeys/metadata.json new file mode 100644 index 0000000..83c1880 --- /dev/null +++ b/awg-ui/src/tabs/apikeys/metadata.json @@ -0,0 +1,6 @@ +{ + "id": "apikeys", + "label": "API-ключи", + "icon": "key", + "order": 2 +} diff --git a/awg-ui/src/tabs/index.ts b/awg-ui/src/tabs/index.ts new file mode 100644 index 0000000..0e781a6 --- /dev/null +++ b/awg-ui/src/tabs/index.ts @@ -0,0 +1,88 @@ +// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs//: +// index.tsx — компонент + логика +// .css — стили вкладки (импортит сам компонент) +// metadata.json — { id, label, icon, order?, chrome? } (манифест: описание + порядок) +// Реестр сам находит все папки через import.meta.glob и собирает «живые» части +// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто +// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно. +// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое +// валидируется здесь в рантайме — кривой/неполный манифест падает с внятной +// ошибкой при сборке TABS, а не «тихо» в UI. +import type { ComponentType } from 'react'; +import type { PageProps } from '../lib/shared'; +import { ICONS } from '../components/icons'; + +// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true. +export interface TabChrome { + serverBar?: boolean; + addServer?: boolean; + restart?: boolean; +} + +// Сериализуемая часть из metadata.json. +export interface TabMeta { + id: string; + label: string; + icon: string; // имя иконки → ICONS + order?: number; // порядок в меню (по возрастанию; без него — 0) + chrome?: TabChrome; +} + +// Собранная вкладка: метадата + резолвнутые Icon и Page. +export interface TabDef extends TabMeta { + Icon: () => JSX.Element; + Page: ComponentType; +} + +const metas = import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true }); +const pages = import.meta.glob<{ default: ComponentType }>('./*/index.tsx', { eager: true }); + +// './users/metadata.json' → 'users' +const folderOf = (path: string) => path.split('/')[1]; + +// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка). +function toMeta(name: string, raw: unknown): TabMeta { + const m = (raw ?? {}) as Record; + const str = (key: string): string => { + const v = m[key]; + if (typeof v !== 'string' || v.trim() === '') { + throw new Error(`tabs/${name}/metadata.json: поле "${key}" должно быть непустой строкой`); + } + return v; + }; + if (m.order !== undefined && typeof m.order !== 'number') { + throw new Error(`tabs/${name}/metadata.json: "order" должно быть числом`); + } + if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) { + throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`); + } + return { + id: str('id'), + label: str('label'), + icon: str('icon'), + order: m.order as number | undefined, + chrome: m.chrome as TabChrome | undefined, + }; +} + +export const TABS: TabDef[] = Object.entries(metas) + .map(([path, mod]) => { + const name = folderOf(path); + const meta = toMeta(name, mod.default); + const page = pages[`./${name}/index.tsx`]?.default; + if (!page) { + throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`); + } + if (!ICONS[meta.icon]) { + throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS (components/icons.tsx)`); + } + return { ...meta, Icon: ICONS[meta.icon], Page: page }; + }) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + +// id вкладок используются как ключ активной вкладки — дубли ломают переключение. +const ids = TABS.map(t => t.id); +const dup = ids.find((id, i) => ids.indexOf(id) !== i); +if (dup) { + throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`); +} diff --git a/awg-ui/src/tabs/users/index.tsx b/awg-ui/src/tabs/users/index.tsx new file mode 100644 index 0000000..f411204 --- /dev/null +++ b/awg-ui/src/tabs/users/index.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import QRCode from 'qrcode'; +import { + apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, + type User, type PageProps, +} from '../../lib/shared'; +import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; +import './users.css'; + +// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный) +// + модалка QR. Сервер-бар сверху рендерит App. Свой стейт и поллинг — здесь. +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 statsRef = useRef | null>(null); + + const loadStats = useCallback(async (tok: string) => { + try { + const data = await apiFetch('GET', '/api/users/stats', tok); + setUsers(prev => prev.map(u => { + const s = (data.users as User[] ?? []).find(x => x.name === u.name); + return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx } : u; + })); + } catch { /* silent */ } + }, []); + + const loadUsers = useCallback(async (tok: string) => { + try { + const data = await apiFetch('GET', '/api/users', tok); + setUsers(data.users ?? []); + await loadStats(tok); + } catch { + showMsg('Ошибка загрузки'); + } + }, [loadStats, showMsg]); + + const createUser = useCallback(async () => { + if (!newName) return; + const u = await apiFetch('POST', '/api/users', token, { name: newName }); + if (u.error) { showMsg(u.error); return; } + setNewName(''); + showMsg('Создан: ' + newName); + await loadUsers(token); + }, [newName, token, loadUsers, showMsg]); + + const deleteUser = useCallback(async (name: string) => { + if (!confirm('Удалить ' + name + '?')) return; + await apiFetch('DELETE', '/api/users/' + name, token); + showMsg('Удалён: ' + name); + await loadUsers(token); + }, [token, loadUsers, showMsg]); + + const showQR = useCallback(async (name: string) => { + try { + const u = await apiFetch('POST', '/api/users/' + name, token); + const vpnKey = u.vpn_key ?? ''; + // errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ), + // при 'H' ёмкость QR падает до ~1273 байт и кодирование падает. + const dataUrl = await QRCode.toDataURL(vpnKey, { + width: 560, + margin: 2, + errorCorrectionLevel: 'L', + }); + setQrModal({ name, dataUrl, vpnKey }); + } catch { + showMsg('Не удалось сгенерировать QR-код'); + } + }, [token, showMsg]); + + // Загрузить пользователей и запустить поллинг статистики, пока вкладка открыта. + useEffect(() => { + if (!token) return; + loadUsers(token); + statsRef.current = setInterval(() => loadStats(token), 60_000); + return () => { if (statsRef.current) clearInterval(statsRef.current); }; + }, [token, loadUsers, loadStats]); + + return ( + <> +
+ setNewName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && createUser()} + /> +
+ + +
+
+ +
+ + + + + + + + + + + + + + {users.length === 0 ? ( + + ) : users.map((u, i) => ( + + + + + + + + + + ))} + +
#СтатусИмяIP↓ rx↑ tx
Нет пользователей
{i + 1} + + + {u.online + ? 'онлайн' + : u.lastHandshake + ? timeAgo(u.lastHandshake) + ' назад' + : 'никогда'} + + {u.name}{u.ip}{bytes(u.rx)}{bytes(u.tx)} +
+ + +
+
+
+ +
+

{users.length} пользователей

+ {users.length === 0 ? ( +

Нет пользователей

+ ) : users.map((u, i) => ( +
+
+
+ #{i + 1} + {u.name} +
+ + + {u.online + ? 'онлайн' + : u.lastHandshake + ? timeAgo(u.lastHandshake) + ' назад' + : 'никогда'} + +
+
+ + {u.ip} +
+
+
+ ↓ rx + {bytes(u.rx)} +
+
+ ↑ tx + {bytes(u.tx)} +
+
+
+ + +
+
+ ))} +
+ + {qrModal && ( +
setQrModal(null)}> +
e.stopPropagation()}> +

{qrModal.name}

+ QR { await copyText(qrModal.vpnKey); showMsg('Ключ скопирован'); }} + /> +

Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать

+
+ + +
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/awg-ui/src/tabs/users/metadata.json b/awg-ui/src/tabs/users/metadata.json new file mode 100644 index 0000000..482b3c6 --- /dev/null +++ b/awg-ui/src/tabs/users/metadata.json @@ -0,0 +1,6 @@ +{ + "id": "users", + "label": "Пользователи", + "icon": "users", + "order": 1 +} diff --git a/awg-ui/src/tabs/users/users.css b/awg-ui/src/tabs/users/users.css new file mode 100644 index 0000000..95131ae --- /dev/null +++ b/awg-ui/src/tabs/users/users.css @@ -0,0 +1,70 @@ +/* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field, + table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */ + +.user-cards-list { display: none; flex-direction: column; gap: 12px; } + +.users-count { font-size: 12px; color: var(--neutral-text); } +.empty-cards { + font-size: 14px; + color: var(--neutral-text); + text-align: center; + padding: 48px 0; +} +.user-card { + background: var(--surface-bright); + border: 1px solid var(--outline-variant); + border-radius: var(--r-card); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.user-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.user-card-title { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} +.user-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; } +.user-card-name { + font-size: 16px; + font-weight: 500; + color: var(--on-surface); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.user-card-ip { + display: flex; + align-items: center; + gap: 8px; + color: var(--neutral-text); +} +.user-card-ip-text { font-family: var(--mono); font-size: 13px; } + +.user-card-metrics { display: flex; gap: 8px; } +.metric-block { + flex: 1; + background: var(--surface-container); + border-radius: var(--r-table); + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 4px; +} +.metric-label { font-size: 12px; color: var(--neutral-text); } +.metric-value { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--on-surface); } + +.user-card-actions { display: flex; gap: 8px; } +.user-card-actions .btn--outline { flex: 1; } + +/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */ +@media (max-width: 640px) { + .user-cards-list { display: flex; } +} diff --git a/awg-ui/src/vite-env.d.ts b/awg-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/awg-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/awg-ui/tsconfig.json b/awg-ui/tsconfig.json new file mode 100644 index 0000000..d55d2f2 --- /dev/null +++ b/awg-ui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src", "vite.config.ts", "server.ts"] +} \ No newline at end of file diff --git a/awg-ui/vite.config.ts b/awg-ui/vite.config.ts new file mode 100644 index 0000000..2fb20d4 --- /dev/null +++ b/awg-ui/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8080', + '/health': 'http://localhost:8080', + '/login': 'http://localhost:8080', + '/logout': 'http://localhost:8080', + }, + }, +}); diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..210a3fc --- /dev/null +++ b/cli/package.json @@ -0,0 +1,12 @@ +{ + "name": "awg-cli", + "version": "0.1.3+1", + "private": true, + "dependencies": { + "tsx": "^4.7.0", + "typescript": "^5.4.0" + }, + "devDependencies": { + "@types/node": "^22.0.0" + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..c914efb --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,363 @@ +// 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 { spawn } from "child_process"; +import { readFileSync, writeFileSync, existsSync, + mkdirSync, openSync, closeSync, + unlinkSync } from "fs"; +import * as readline from "readline"; +import * as crypto from "crypto"; +import * as http from "http"; +import path from "path"; + +const ROOT = path.resolve(__dirname, "../.."); +const LOGS = path.join(ROOT, "logs"); + +if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true }); + +const envFile = path.join(__dirname, "..", "cli.env"); +if (existsSync(envFile)) { + for (const line of readFileSync(envFile, "utf8").split("\n")) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + const idx = t.indexOf("="); + if (idx === -1) continue; + const k = t.slice(0, idx).trim(); + const v = t.slice(idx + 1).trim(); + process.env[k] ??= v; + } +} + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +const C = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + green: "\x1b[32m", + grey: "\x1b[90m", + blue: "\x1b[34m", +}; + +const bold = (s: string) => `${C.bold}${s}${C.reset}`; +const green = (s: string) => `${C.green}${s}${C.reset}`; +const grey = (s: string) => `${C.grey}${s}${C.reset}`; +const dim = (s: string) => `${C.dim}${s}${C.reset}`; + +const SERVICES = { + awgctrl: { + label: "awg-ctrl", + cwd: path.join(ROOT, "awg-ctrl"), + entry: "index.ts", + env: { + PORT: process.env.AWGCTRL_PORT ?? "3005", + SERVER_IP: process.env.SERVER_IP ?? "", + SERVER_PORT: process.env.SERVER_PORT ?? "47619", + SERVER_NAME: process.env.SERVER_NAME ?? "VPN", + // Публичный ключ внутренней авторизации (awg-ctrl проверяет им токены awg-ui). + INTERNAL_AUTH_PUB_FILE: process.env.INTERNAL_AUTH_PUB_FILE + ?? "/etc/amnezia/amneziawg/internal_auth_public.key", + }, + pidFile: "/tmp/awg-ctrl.pid", + logFile: path.join(LOGS, "awg-ctrl.log"), + }, + ui: { + label: "awg-ui", + cwd: path.join(ROOT, "awg-ui"), + entry: "server.ts", + env: { + PORT: process.env.UI_PORT ?? "8080", + AWGCTRL_PORT: process.env.AWGCTRL_PORT ?? "3005", + // Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl). + INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE + ?? "/etc/amnezia/amneziawg/internal_auth_private.key", + UI_USER: process.env.UI_USER ?? "admin", + UI_PASS: process.env.UI_PASS ?? "", + JWT_SECRET: process.env.JWT_SECRET ?? "", + // ui.db в каталоге данных AWG (вне PROJECT) — переживает переустановку. + UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db", + }, + pidFile: "/tmp/awg-ui.pid", + logFile: path.join(LOGS, "awg-ui.log"), + }, +} as const; + +type SvcName = keyof typeof SERVICES; +const ALL: SvcName[] = ["awgctrl", "ui"]; + +function readPid(name: SvcName): number | null { + const { pidFile } = SERVICES[name]; + if (!existsSync(pidFile)) return null; + const n = parseInt(readFileSync(pidFile, "utf8").trim()); + return isNaN(n) ? null : n; +} + +function isAlive(pid: number): boolean { + // EPERM → процесс существует, просто не наш (трактуем как «жив»). + try { process.kill(pid, 0); return true; } + catch (e: any) { return e?.code === "EPERM"; } +} + +// Защита от переиспользования PID: на Linux сверяем cmdline процесса +// с entry-файлом сервиса. Если procfs недоступен — проверить нечем. +function pidMatchesService(name: SvcName, pid: number): boolean { + const cmdlinePath = `/proc/${pid}/cmdline`; + if (!existsSync(cmdlinePath)) return true; + try { + const cmdline = readFileSync(cmdlinePath, "utf8"); + return cmdline.includes(SERVICES[name].entry); + } catch { return true; } +} + + + + +function isRunning(name: SvcName): boolean { + const pid = readPid(name); + return pid !== null && isAlive(pid) && pidMatchesService(name, pid); +} + +async function start(name: SvcName) { + const svc = SERVICES[name]; + if (isRunning(name)) { + console.log(` ${svc.label}: already running pid=${readPid(name)}`); + return; + } + if (name === "awgctrl" && "INTERNAL_AUTH_PUB_FILE" in svc.env && !existsSync(svc.env.INTERNAL_AUTH_PUB_FILE)) { + console.error(" awgctrl: публичный ключ внутренней авторизации не найден: " + svc.env.INTERNAL_AUTH_PUB_FILE); + process.exit(1); + } + + // npx завершается сразу после передачи управления node → его PID мёртв. + // Запускаем tsx напрямую из node_modules/.bin/, чтобы child.pid был + // реальным PID сервера. Ищем бинарник и в самом сервисе, и в корне + // (на случай hoisting зависимостей в общий node_modules). + const candidates = [ + path.join(svc.cwd, "node_modules/.bin/tsx"), + path.join(ROOT, "node_modules/.bin/tsx"), + ]; + const tsxBin = candidates.find(existsSync); + const bin = tsxBin ?? "npx"; + const args = tsxBin ? [svc.entry] : ["tsx", svc.entry]; + + const logFd = openSync(svc.logFile, "a"); + const child = spawn(bin, args, { + cwd: svc.cwd, + env: { ...process.env, ...svc.env }, + detached: true, + stdio: ["ignore", logFd, logFd], + }); + + let spawnError: Error | null = null; + child.on("error", e => { spawnError = e; }); + + // Даём spawn шанс упасть (ENOENT и т.п.) до записи pid-файла. + await sleep(50); + closeSync(logFd); // fd унаследован ребёнком — родителю не нужен + + if (spawnError || !child.pid) { + console.error(` ${svc.label}: не удалось запустить — ` + + `${spawnError ? (spawnError as Error).message : "нет PID"}`); + try { unlinkSync(svc.pidFile); } catch {} + return; + } + + child.unref(); + writeFileSync(svc.pidFile, String(child.pid)); + console.log(` ${green("▶")} ${svc.label}: started pid=${child.pid}`); +} + +async function stop(name: SvcName) { + const svc = SERVICES[name]; + const pid = readPid(name); + + if (pid === null || !isAlive(pid) || !pidMatchesService(name, pid)) { + console.log(` ${svc.label}: not running`); + try { unlinkSync(svc.pidFile); } catch {} + return; + } + + try { process.kill(pid, "SIGTERM"); } catch {} + + // Ждём мягкого завершения до 5 с, иначе добиваем SIGKILL. + const deadline = Date.now() + 5000; + while (isAlive(pid) && Date.now() < deadline) await sleep(100); + if (isAlive(pid)) { + try { process.kill(pid, "SIGKILL"); } catch {} + await sleep(200); + } + + try { unlinkSync(svc.pidFile); } catch {} + console.log(` ${grey("■")} ${svc.label}: stopped pid=${pid}`); +} + +async function restart(name: SvcName) { + await stop(name); + await start(name); +} + +function status() { + console.log(""); + for (const name of ALL) { + const running = isRunning(name); + const pid = readPid(name); + const dot = running ? green("●") : grey("○"); + const info = running ? green("running") + ` pid ${pid}` : grey("stopped"); + console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`); + } + console.log(""); +} + +// HTTP-проба: любой ответ (даже 401/503) означает, что порт слушается. +function probe(url: string, timeoutMs = 1000): Promise { + return new Promise(resolve => { + const req = http.get(url, res => { res.resume(); resolve(true); }); + req.on("error", () => resolve(false)); + req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); }); + }); +} + +// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например, +// ensureInterfaceUp() бросил исключение) — сразу выходим. +async function waitForHealth(name: SvcName, attempts = 10): Promise { + const url = `http://127.0.0.1:${SERVICES[name].env.PORT}/health`; + for (let i = 0; i < attempts; i++) { + if (!isRunning(name)) return false; + if (await probe(url)) return true; + await sleep(300); + } + return false; +} + +async function startAll() { + await start("awgctrl"); + if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) { + console.error(` ${grey("■")} awg-ctrl не отвечает — awg-ui не запущен (см. ${SERVICES.awgctrl.logFile})`); + return; + } + await start("ui"); +} + +async function stopAll() { for (const n of ALL) await stop(n); } +async function restartAll() { await stopAll(); await startAll(); } + +function updateEnvVar(content: string, key: string, value: string): string { + const re = new RegExp(`^${key}=.*$`, "m"); + return re.test(content) + ? content.replace(re, `${key}=${value}`) + : content.trimEnd() + `\n${key}=${value}\n`; +} + +async function setCredentials(field?: "user" | "pass") { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string) => new Promise(r => rl.question(q, r)); + + let user: string | undefined; + let pass: string | undefined; + + if (!field || field === "user") { + const cur = process.env.UI_USER ?? "admin"; + user = ((await ask(`\n Логин [${cur}]: `)).trim()) || cur; + } + if (!field || field === "pass") { + const generated = crypto.randomBytes(9).toString("base64url").slice(0, 12); + pass = ((await ask(` Пароль [${generated}]: `)).trim()) || generated; + } + + rl.close(); + + let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : ""; + if (user !== undefined) { content = updateEnvVar(content, "UI_USER", user); process.env.UI_USER = user; } + if (pass !== undefined) { content = updateEnvVar(content, "UI_PASS", pass); process.env.UI_PASS = pass; } + writeFileSync(envFile, content, "utf8"); + + if (user !== undefined) console.log(`\n ${green("✓")} Логин: ${user}`); + if (pass !== undefined) console.log(` ${green("✓")} Пароль: ${pass}`); + console.log(`\n ${dim("Перезапусти UI чтобы изменения вступили в силу.")}`); +} + +async function interactiveMenu() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const ask = (prompt: string): Promise => + new Promise(resolve => rl.question(prompt, resolve)); + + const printMenu = () => { + console.clear(); + console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.3.1 ──${C.reset}`)}\n`); + + for (const name of ALL) { + const running = isRunning(name); + const pid = readPid(name); + const dot = running ? green("●") : grey("○"); + const info = running + ? green("running") + dim(` pid ${pid}`) + : grey("stopped"); + console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`); + } + + console.log(` + ${bold("1")} Запустить + ${bold("2")} Остановить + ${bold("3")} Перезапустить + ${bold("4")} Статус + ${dim("──────────────")} + ${bold("5")} Сменить логин UI + ${bold("6")} Сменить пароль UI + ${dim("──────────────")} + ${bold("0")} Выход +`); + }; + + while (true) { + printMenu(); + const choice = (await ask(" › ")).trim(); + console.log(""); + + switch (choice) { + case "1": await startAll(); break; + case "2": await stopAll(); break; + case "3": await restartAll(); break; + case "4": status(); break; + case "5": await setCredentials("user"); break; + case "6": await setCredentials("pass"); break; + case "0": rl.close(); process.exit(0); + default: console.log(grey(" Неверный выбор")); break; + } + + if (choice !== "0") { + await ask(`\n ${dim("Enter для продолжения...")}`); + } + } +} + +async function main() { + const [,, cmd] = process.argv; + + if (!cmd) { + await interactiveMenu(); + return; + } + + const USAGE = "Usage: cli "; + + const [,,, sub] = process.argv; + + switch (cmd) { + case "start": await startAll(); break; + case "stop": await stopAll(); break; + case "restart": await restartAll(); break; + case "status": status(); break; + case "credentials": + if (sub === "user") await setCredentials("user"); + else if (sub === "pass") await setCredentials("pass"); + else await setCredentials(); + break; + default: console.log(USAGE); process.exit(1); + } +} + +main().catch(e => { console.error(e); process.exit(1); }); \ No newline at end of file diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..1efdceb --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..99cd86c --- /dev/null +++ b/install.sh @@ -0,0 +1,624 @@ +# 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. +#!/usr/bin/env bash +# sudo bash <(curl -Ls https://amnesia.ma7neko.ru/forgeting/install.sh) + +set -euo pipefail + +GRN='\033[0;32m'; YLW='\033[1;33m'; RED='\033[0;31m'; BLU='\033[0;34m' +BLD='\033[1m'; NC='\033[0m' + + +ok() { echo -e " ${GRN}✓${NC} $*"; } +warn() { echo -e " ${YLW}⚠${NC} $*"; } +fail() { trap - ERR; echo -e "\n ${RED}✗${NC} $*" >&2; exit 1; } +step() { echo -e "\n${BLD}${BLU}── $* ──${NC}"; } + +trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} строка ${LINENO} код ${rc}\n команда: ${BASH_COMMAND}\n полный лог: ${LOGFILE:-<ещё не открыт>}" >&2' ERR + +[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh" +[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh" + +VERSION="0.1.3.1" +BASE_URL="https://amnesia.ma7neko.ru/forgeting" # хост с install.sh и архивами +PROJECT="/opt/awg-control" +AMNEZIA_DIR="/etc/amnezia" +AWG_DIR="$AMNEZIA_DIR/amneziawg" +PRIV_KEY_FILE="$AMNEZIA_DIR/server_private.key" +PUB_KEY_FILE="$AWG_DIR/server_public.key" +AWG_CONF="$AWG_DIR/awg1.conf" +DB_FILE="$AWG_DIR/users.db" +UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PROJECT — переживает переустановку +# Внутренняя авторизация 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" +IFACE="awg1" +AWG_PORT="47619" +SUBNET="10.9" +MTU="1376" + +# Запуск CLI: tsx напрямую из node_modules (не npx) — чтобы PID был реальным +# (та же причина, что у обёртки /usr/local/bin/awg-ctrl). Нужно обоим режимам. +TSX="$PROJECT/cli/node_modules/.bin/tsx" +CLI="$PROJECT/cli/src/index.ts" + +echo -e "${BLD}Forgetting Alpha ${VERSION}${NC}" +echo + +# Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой +# машине лучше упасть сразу, а не после ввода данных или удаления каталога. +# Порты (UDP VPN / порт UI) здесь НЕ проверяем — это отдельно (фаервол/облако). +echo -e "${BLD}Проверка совместимости:${NC}" + +KERNEL=$(uname -r) +VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown") +echo " Ядро: $KERNEL · виртуализация: $VIRT · $(lsb_release -ds 2>/dev/null || echo unknown)" + +# Считаем все три пункта, НЕ падая на первом, — чтобы показать полный чеклист +# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим +# с кодом 0 (чистый выход, без вида «упало с ошибкой»). +VIRT_OK=1; HDR_OK=1; NET_OK=1 +VIRT_WHY=""; HDR_WHY=""; NET_WHY="" + +# 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом. +case "$VIRT" in + openvz|lxc|lxc-libvirt|docker|podman|wsl) + VIRT_OK=0 + VIRT_WHY="виртуализация '$VIRT' — ядро общее с хостом, kernel-модуль AmneziaWG не загрузить (нужен userspace amneziawg-go)" + ;; +esac + +# 2) Заголовки ядра: DKMS соберёт модуль только при их наличии под текущее ядро. +# Уже в системе или есть кандидат в apt — ок. «(none)» — кастомное ядро, не +# проходит. Пустой apt-индекс не валим: добьёт поздняя проверка на шаге AWG. +HDR_POLICY=$(apt-cache policy "linux-headers-$KERNEL" 2>/dev/null || true) +if [[ -d "/lib/modules/$KERNEL/build" ]]; then + : +elif echo "$HDR_POLICY" | grep -q 'Candidate: [^(]'; then + : +elif echo "$HDR_POLICY" | grep -q 'Candidate: (none)'; then + HDR_OK=0 + HDR_WHY="нет заголовков под ядро $KERNEL в apt (кастомное ядро провайдера). Решение: apt-get install -y linux-generic && reboot, затем запусти install.sh заново в generic-ядре" +fi + +# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой +# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS. +if ! curl -sS --connect-timeout 8 -o /dev/null "$BASE_URL/" 2>/dev/null; then + NET_OK=0 + NET_WHY="нет доступа к $BASE_URL — проверь интернет и DNS" +fi + +# Чеклист: ✓ — пройдено, ✗ — нет. +mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; } +mark "$VIRT_OK" "Виртуализация" +mark "$HDR_OK" "Заголовки ядра" +mark "$NET_OK" "Доступ в интернет" + +if [[ "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then + echo + warn "Установка невозможна — не пройдены проверки:" + [[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY" + [[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY" + [[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY" + echo + trap - ERR + exit 0 +fi + +# Наличие базы определяем заранее и молча — от него зависит, спросим ли ниже имя +# сервера (если база есть, имя берётся из неё). Без вывода. KEEP_DATA — отдельно. +DB_EXISTS="n" +[[ -f "$DB_FILE" ]] && DB_EXISTS="y" +echo + +# TODO: рандомизировать параметры обфускации при установке. +# awg-ctrl уже читает их из [Interface] awg1.conf (readAwgParams), поэтому +# достаточно генерировать случайные значения здесь — править awg-ctrl не нужно. +# Ограничения, которые обязан соблюсти генератор: +# - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU +# - S1, S2: < ~150, в части версий S1 != S2 +# - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные +# типы сообщений WireGuard), большие uint32 без пересечений +# - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами +# Параметры фиксируются на весь срок жизни сервера: при KEEP_DATA=y их +# менять нельзя (иначе все ранее выданные vpn:// ключи станут невалидными). +JC=6; JMIN=10; JMAX=50 +S1=90; S2=45; S3=37; S4=14 +H1="1224800044-2116730834" +H2="2122053282-2133204808" +H3="2133604274-2140756116" +H4="2143656228-2147444225" + +NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') +[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" + +# Сначала корректно через CLI (по PID-файлам), затем добиваем всё, что ещё +# держит файлы проекта: осиротевшие процессы, ручной запуск или stale PID, +# которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе node +# продолжит работать с уже удалёнными файлами и порт/интерфейс останутся занятыми. +kill_related() { + local _tsx="$PROJECT/cli/node_modules/.bin/tsx" + local _cli="$PROJECT/cli/src/index.ts" + [[ -x "$_tsx" && -f "$_cli" ]] && "$_tsx" "$_cli" stop all 2>/dev/null || true + + # cmdline всех сервисов (tsx awg-ctrl/awg-ui/cli) содержит путь проекта. + if command -v pkill &>/dev/null; then + pkill -TERM -f "$PROJECT/" 2>/dev/null || true + sleep 1 + pkill -KILL -f "$PROJECT/" 2>/dev/null || true + else + local _pids + _pids=$(ps -eo pid=,args= | awk -v p="$PROJECT/" 'index($0,p){print $1}') + [[ -n "$_pids" ]] && kill -TERM $_pids 2>/dev/null || true + sleep 1 + _pids=$(ps -eo pid=,args= | awk -v p="$PROJECT/" 'index($0,p){print $1}') + [[ -n "$_pids" ]] && kill -KILL $_pids 2>/dev/null || true + fi + + rm -f /tmp/awg-ctrl.pid /tmp/awg-ui.pid 2>/dev/null || true +} + +# Логирование: дублируем stdout/stderr в файл лога через tee. LOGFILE — глобал, +# на него ссылается ERR-трап. +start_logging() { + LOGFILE="/var/log/awg-install-$(date +%Y%m%d-%H%M%S).log" + exec > >(tee -a "$LOGFILE") 2>&1 + echo -e " ${BLD}Лог установки:${NC} $LOGFILE" +} + +# Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые +# файлы на месте. +download_extract() { + local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz" + local tmp="/tmp/awgcontrol-${VERSION}.tar.gz" + echo " → версия: ${VERSION}" + curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \ + || fail "Не удалось скачать архив: $url" + ok "Архив скачан: $(du -sh "$tmp" | cut -f1)" + + mkdir -p "$PROJECT" + tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \ + || fail "Не удалось распаковать архив" + rm -f "$tmp" + ok "Распакован → $PROJECT" + + local f + for f in \ + "$PROJECT/awg-ctrl/index.ts" \ + "$PROJECT/awg-ctrl/package.json" \ + "$PROJECT/awg-ui/public/index.html" \ + "$PROJECT/awg-ui/server.ts" \ + "$PROJECT/awg-ui/package.json" \ + "$PROJECT/cli/src/index.ts" \ + "$PROJECT/cli/package.json" + do + [[ -f "$f" ]] || fail "Файл не найден после распаковки: $f" + done + ok "Архив проверен" +} + +# npm install во всех трёх сервисах. +npm_install_all() { + local SVC + for SVC in awg-ctrl awg-ui cli; do + if [[ -f "$PROJECT/$SVC/package.json" ]]; then + echo -n " $SVC ... " + (cd "$PROJECT/$SVC" && npm install --silent) \ + || fail "npm install в $SVC завершился ошибкой" + echo -e "${GRN}ok${NC}" + else + warn "$PROJECT/$SVC/package.json не найден — пропуск" + fi + done +} + +# systemd-юнит: автозапуск awg-control на загрузке. Модель CLI — детач-процессы +# с PID-файлами (а не один долгоживущий процесс), поэтому Type=oneshot + +# RemainAfterExit: systemd держит юнит «active», процессами рулит CLI через +# start/stop. awg-ctrl падает, если awg1 не поднят, а на загрузке awg-quick up +# вручную не выполняется — поднимаем интерфейс в ExecStartPre (если ещё не поднят). +SERVICE_UNIT="/etc/systemd/system/awg-control.service" +setup_service() { + # node может стоять вне дефолтного PATH systemd (nvm и т.п.), а tsx + # запускается через shebang `#!/usr/bin/env node` — иначе ExecStart падает с + # кодом 127 «node not found». Прописываем реальный каталог node в PATH юнита; + # sbin тоже включаем (awg-quick зовёт iptables/ip/sysctl). + local node_dir + node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)") + cat > "$SERVICE_UNIT" </dev/null 2>&1 || awg-quick up ${IFACE}' +ExecStart=/usr/local/bin/awg-ctrl start all +ExecStop=/usr/local/bin/awg-ctrl stop all + +[Install] +WantedBy=multi-user.target +UNIT + systemctl daemon-reload + systemctl enable awg-control.service >/dev/null 2>&1 \ + || warn "systemctl enable awg-control не удался — автозапуск не настроен" + ok "systemd-юнит awg-control установлен (автозапуск на загрузке)" +} + +# Запуск/перезапуск сервисов + статус. Через systemd, если он есть (тогда +# работает автозапуск на загрузке); иначе — напрямую через CLI, без автозапуска. +start_and_status() { + if command -v systemctl >/dev/null 2>&1; then + setup_service + systemctl restart awg-control.service \ + || fail "systemctl restart awg-control завершился ошибкой" + else + warn "systemd не найден — запускаю напрямую через CLI (без автозапуска)" + "$TSX" "$CLI" start all + fi + sleep 2 + echo + "$TSX" "$CLI" status +} + +INSTALL_MODE="fresh" +if [[ -d "$PROJECT" ]]; then + echo + warn "Найден каталог $PROJECT" + echo " 1) Обновить до версии ${VERSION} — сохранить настройки и пользователей" + echo " 2) Полностью переустановить — удалить каталог и начать заново" + read -rp " Выбери [1/2]: " INST_CHOICE + case "${INST_CHOICE:-1}" in + 1) INSTALL_MODE="update" ;; + 2) INSTALL_MODE="fresh" ;; + *) fail "Неверный выбор: введи 1 или 2" ;; + esac + echo +fi + +if [[ "$INSTALL_MODE" == "update" ]]; then + start_logging + step "Обновление до версии ${VERSION}" + + echo " → останавливаем и убиваем все процессы awg-control" + kill_related + + download_extract + npm_install_all + start_and_status + + echo + echo -e "${BLD}${GRN}✓ Обновление завершено — версия ${VERSION}${NC}" + echo + exit 0 +fi + +# При полной переустановке убиваем все процессы awg-control и очищаем старый +# каталог, чтобы не оставалось ни запущенных процессов, ни старых файлов. +if [[ -d "$PROJECT" ]]; then + kill_related + rm -rf "$PROJECT" +fi + +step "Конфигурация" + +# Server IP определяем автоматически (внешний через ifconfig.me, иначе локальный +# по маршруту) — не спрашиваем. Если определить не удалось — падаем. +SERVER_IP=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null \ + || ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' \ + || echo "") +[[ -z "$SERVER_IP" ]] && fail "Не удалось определить Server IP автоматически" +ok "Server IP: $SERVER_IP" + +# Имя сервера спрашиваем ниже — только если базы ещё нет (DB_EXISTS=n). +# Если база есть, имя берётся из неё. + +# Внутренняя авторизация awg-ui ↔ awg-ctrl — асимметричная пара, генерируется +# в шаге 4 (Ключи). Здесь секрет не нужен. +AWGCTRL_PORT=$(( (RANDOM % 22768) + 32768 )) + +read -rp " UI port (Enter — случайный): " UI_PORT +UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}" + +read -rp " UI логин [admin]: " UI_USER +UI_USER="${UI_USER:-admin}" + +SUGGESTED_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12 2>/dev/null || openssl rand -hex 6) +read -rsp " UI пароль [$SUGGESTED_PASS]: " UI_PASS; echo +UI_PASS="${UI_PASS:-$SUGGESTED_PASS}" + +JWT_SECRET=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 32 2>/dev/null || openssl rand -hex 16) + +# Если найдены база пользователей и ключи сервера — предлагаем их сохранить. +# Важно: vpn:// ключи пользователей привязаны к ключу сервера, поэтому +# сохранять базу имеет смысл только вместе со старым ключом сервера. +KEEP_DATA="n" +if [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then + echo + warn "Найдена существующая установка:" + [[ -f "$DB_FILE" ]] && echo " база пользователей: $DB_FILE" + [[ -f "$PRIV_KEY_FILE" ]] && echo " ключ сервера: $PRIV_KEY_FILE" + + if [[ -f "$DB_FILE" && -f "$PRIV_KEY_FILE" && -f "$PUB_KEY_FILE" ]]; then + read -rp " Сохранить пользователей и ключ сервера? [Y/n]: " KD + [[ "${KD:-y}" =~ ^[Yy]$ || -z "${KD}" ]] && KEEP_DATA="y" + else + warn "Для сохранения нужны и база, и оба ключа сервера — часть отсутствует." + warn "Пользователей не сохранить (vpn:// ключи стали бы невалидными)." + fi +fi + +# Имя сервера: если база уже существует (DB_EXISTS, проверено заранее в +# preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем. +if [[ "$DB_EXISTS" == "y" ]]; then + SERVER_NAME="VPN" + echo " Server name: берётся из существующей базы (пропускаем)" +else + read -rp " Server name [VPN]: " SERVER_NAME + SERVER_NAME="${SERVER_NAME:-VPN}" +fi + +echo " Project dir: $PROJECT" +echo " Server IP: $SERVER_IP" +echo " Server name: $SERVER_NAME" +echo " AWG port: $AWG_PORT (udp)" +echo " awgctrl port: $AWGCTRL_PORT" +echo " UI port: $UI_PORT" +echo " UI логин: $UI_USER" +echo " Net interface: $NET_IFACE" +if [[ "$KEEP_DATA" == "y" ]]; then + echo -e " Данные: ${GRN}сохранить существующих пользователей и ключ${NC}" +elif [[ -f "$DB_FILE" ]]; then + echo -e " Данные: ${YLW}новая установка (старая база → бэкап)${NC}" +fi +echo + +read -rp " Продолжить? [Y/n]: " YN +[[ "${YN:-y}" =~ ^[Nn]$ ]] && { echo " Отменено."; exit 0; } + +start_logging + +# Совместимость (виртуализация, заголовки ядра, интернет) проверена выше в +# секции «Проверка совместимости»; $KERNEL задан там же. +step "1/7 AmneziaWG" + +if command -v awg &>/dev/null && command -v awg-quick &>/dev/null && modinfo amneziawg &>/dev/null; then + ok "AWG уже установлен (модуль amneziawg: $(modinfo -F version amneziawg 2>/dev/null || echo present))" +else + echo " → apt-get update" + apt-get update || fail "apt-get update упал — проверь /etc/apt/sources.list*" + + echo " → установка зависимостей сборки + заголовков ядра" + DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ + apt-get install -y \ + software-properties-common \ + python3-launchpadlib \ + gnupg2 \ + dkms \ + build-essential \ + "linux-headers-$KERNEL" \ + linux-headers-generic \ + || fail "Не удалось установить зависимости или заголовки ядра" + + if [[ ! -d "/lib/modules/$KERNEL/build" ]]; then + warn "Нет /lib/modules/$KERNEL/build — заголовки под текущее ядро отсутствуют." + + warn "Часто бывает на кастомном ядре провайдера. Решение:" + warn " apt-get install -y linux-generic && reboot" + warn "и после загрузки в generic-ядро запусти install.sh заново." + fail "Отсутствуют заголовки ядра $KERNEL — DKMS не соберёт модуль" + fi + ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build" + + echo " → add-apt-repository ppa:amnezia/ppa" + add-apt-repository -y ppa:amnezia/ppa \ + || fail "Не удалось добавить PPA ppa:amnezia/ppa" + + echo " → apt-get update (после PPA)" + apt-get update \ + || fail "apt-get update после PPA упал — проверь источники amnezia в /etc/apt" + + echo " → установка amneziawg (сборка DKMS-модуля, может занять до минуты)" + if ! DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ + apt-get install -y amneziawg; then + warn "apt-get install amneziawg завершился с ошибкой." + MKLOG=$(ls -1t /var/lib/dkms/amneziawg/*/build/make.log 2>/dev/null | head -1 || true) + if [[ -n "${MKLOG:-}" && -f "$MKLOG" ]]; then + echo " ───── $MKLOG (последние 40 строк) ─────" + tail -n 40 "$MKLOG" | sed 's/^/ /' + echo " ───────────────────────────────────────────────" + else + warn "make.log не найден — ошибка, вероятно, на этапе apt/репозиториев." + fi + fail "Не удалось установить amneziawg (детали выше и в $LOGFILE)" + fi + + echo " → проверка собранного модуля" + dkms status amneziawg 2>/dev/null | sed 's/^/ /' || true + if ! modprobe amneziawg 2>/dev/null; then + MKLOG=$(ls -1t /var/lib/dkms/amneziawg/*/build/make.log 2>/dev/null | head -1 || true) + [[ -n "${MKLOG:-}" && -f "$MKLOG" ]] && { echo " ── make.log (tail) ──"; tail -n 40 "$MKLOG" | sed 's/^/ /'; } + fail "Модуль amneziawg не загрузился — DKMS-сборка несовместима с ядром" + fi + ok "AWG установлен, модуль amneziawg собран и загружается" +fi + +step "2/7 Node.js" + +if command -v node &>/dev/null; then + ok "Node.js уже установлен: $(node --version)" +else + echo " → установка Node.js 20.x (nodesource)" + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs || fail "Не удалось установить Node.js" + ok "Node.js установлен: $(node --version)" +fi + +step "3/7 Файлы проекта" + +download_extract + +step "4/7 Ключи и конфиг AWG" + +mkdir -p "$AWG_DIR" "$AMNEZIA_DIR" + +AWG_BIN=$(which awg || echo /usr/bin/awg) + +if [[ "$KEEP_DATA" == "y" ]]; then + # Переиспользуем существующий ключ сервера — иначе старые vpn:// ключи + # пользователей в users.db станут невалидными. + PRIV_KEY=$(cat "$PRIV_KEY_FILE") + PUB_KEY=$(cat "$PUB_KEY_FILE") + ok "Используются существующие ключи сервера (база сохранена): $PUB_KEY" +else + # Новая установка. Существующую базу не удаляем, а отправляем в бэкап, + # чтобы awg-ctrl создал чистую users.db при старте. + if [[ -f "$DB_FILE" ]]; then + DB_BAK="${DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)" + mv "$DB_FILE" "$DB_BAK" + warn "Старая база пользователей сохранена: $DB_BAK" + fi + # ui.db (API-ключи awg-ui) — тоже в бэкап, чтобы awg-ui создал чистую БД. + if [[ -f "$UI_DB_FILE" ]]; then + UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)" + mv "$UI_DB_FILE" "$UI_DB_BAK" + # WAL-сайдкары удаляем — к новой БД они неприменимы. + rm -f "${UI_DB_FILE}-wal" "${UI_DB_FILE}-shm" + warn "Старая база API-ключей сохранена: $UI_DB_BAK" + fi + + PRIV_KEY=$(umask 077 && awg genkey) + PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey) + + umask 077 + printf '%s' "$PRIV_KEY" > "$PRIV_KEY_FILE" + printf '%s' "$PUB_KEY" > "$PUB_KEY_FILE" + chmod 600 "$PRIV_KEY_FILE" "$PUB_KEY_FILE" + + ok "Публичный ключ: $PUB_KEY" +fi + +# Внутренняя авторизация awg-ui → awg-ctrl: Ed25519-пара. Приватный → awg-ui, +# публичный → awg-ctrl. Перегенерируется при каждой установке (эфемерна: обе +# стороны переписываются вместе) — даже при KEEP_DATA, на vpn:// ключи не влияет. +( umask 077 + openssl genpkey -algorithm ed25519 -out "$INTERNAL_AUTH_PRIV" + openssl pkey -in "$INTERNAL_AUTH_PRIV" -pubout -out "$INTERNAL_AUTH_PUB" ) +chmod 600 "$INTERNAL_AUTH_PRIV" "$INTERNAL_AUTH_PUB" +ok "Ключи внутренней авторизации awg-ui ↔ awg-ctrl" + +grep -qxF 'net.ipv4.ip_forward=1' /etc/sysctl.conf \ + || echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf +grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \ + || echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.conf +sysctl -qp +ok "IP forwarding включён" + +cat > "$AWG_CONF" </dev/null; then + warn "Интерфейс $IFACE уже существует — перезапускаем" + awg-quick down "$IFACE" 2>/dev/null || true +fi + +awg-quick up "$IFACE" || fail "awg-quick up $IFACE завершился с ошибкой" + +# awg-quick не всегда применяет приватный ключ из [Interface]/PostUp +# (наблюдалось: awg show public-key = none сразу после up). Если ключ не +# совпал — доставляем его явно из файла и проверяем ещё раз. +RUNNING_PUB=$(awg show "$IFACE" public-key 2>/dev/null || echo "") +if [[ "$RUNNING_PUB" != "$PUB_KEY" ]]; then + awg set "$IFACE" private-key "$PRIV_KEY_FILE" \ + || fail "Не удалось применить приватный ключ к $IFACE" + RUNNING_PUB=$(awg show "$IFACE" public-key 2>/dev/null || echo "") +fi + +if [[ "$RUNNING_PUB" == "$PUB_KEY" ]]; then + ok "Интерфейс $IFACE запущен, ключ применён" +else + fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY" +fi + +step "6/7 npm install" + +npm_install_all + +# Глобальный бинарник — используем tsx напрямую из node_modules (не npx) +# чтобы child.pid в cli был реальным PID процесса +cat > /usr/local/bin/awg-ctrl << 'WRAPPER' +#!/usr/bin/env bash +exec /opt/awg-control/cli/node_modules/.bin/tsx \ + /opt/awg-control/cli/src/index.ts "$@" +WRAPPER +chmod +x /usr/local/bin/awg-ctrl +ok "awg-ctrl → /usr/local/bin/awg-ctrl" + +step "7/7 Запуск сервисов" + +cat > "$PROJECT/cli/cli.env" <