Initial commit

This commit is contained in:
2026-07-10 00:19:05 +03:00
commit 54ffcf9bc0
29 changed files with 3832 additions and 0 deletions
+22
View File
@@ -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
+21
View File
@@ -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.
+591
View File
@@ -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: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
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<typeof generateKeys>,
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<typeof generateKeys>,
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<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> {
try {
const output = run(`awg show ${CONFIG.interface} dump`);
const result: Record<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> = {};
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<void>) {
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();
+23
View File
@@ -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"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": [
""
]
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AWG Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=/"></head></html>
+251
View File
@@ -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<string>();
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<string, RateEntry>();
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}`); });
+698
View File
@@ -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; }
}
+259
View File
@@ -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<ServerInfo | null>(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 (
<div className="app">
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && showRestart && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
title="Перезапустить AWG-интерфейс"
onClick={restartAwg}
disabled={restarting}
>
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
</button>
)}
{!token ? (
<div className="login-screen">
<div className="login-card">
<span className="logo-name">Forgetting</span>
<p className="login-sub">Войдите, чтобы продолжить</p>
<input
className="field"
placeholder="Логин"
value={loginUser}
onChange={e => setLoginUser(e.target.value)}
autoFocus
/>
<input
className="field"
type="password"
placeholder="Пароль"
value={loginPass}
onChange={e => setLoginPass(e.target.value)}
onKeyDown={e => e.key === 'Enter' && login()}
/>
<button className="btn btn--primary btn--full" onClick={login}>
Войти
</button>
{statusText.startsWith('offline:') && (
<p className="login-error">{statusLabel}</p>
)}
</div>
</div>
) : (
<>
{/* Top bar — видим только на мобильном */}
<header className="top-bar">
<button
className="hamburger-btn"
aria-label="Открыть меню"
onClick={() => setDrawerOpen(true)}
>
<IcoMenu />
</button>
<span className="top-bar-title">{tab.label}</span>
</header>
{/* Затемнение под drawer */}
<div
className={`drawer-scrim${drawerOpen ? ' drawer-scrim--visible' : ''}`}
onClick={() => setDrawerOpen(false)}
/>
<div className="layout">
<aside
className={`sidebar${drawerOpen ? ' sidebar--open' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="sidebar-logo">
<span className="logo-name">Forgetting</span>
<span className="sidebar-version">Alpha 0.1.3.1</span>
</div>
<hr className="drawer-divider" />
{TABS.map(t => (
<div
key={t.id}
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
))}
<div className="sidebar-spacer" />
<button className="btn btn--danger" onClick={logout}>
<IcoLogout /> Выйти
</button>
</aside>
<main className="main">
<h2 className="page-title">{tab.label}</h2>
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
{showServerBar && (
<ServerBar
serverInfo={serverInfo}
serverOnline={isOnline}
onRestartAwg={restartAwg}
restarting={restarting}
showAddServer={chrome.addServer !== false}
showRestart={showRestart}
/>
)}
<tab.Page token={token} showMsg={showMsg} />
</main>
</div>
</>
)}
{msg && <div className="snack">{msg}</div>}
</div>
);
}
+62
View File
@@ -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 (
<div className="server-bar">
{serverInfo && (
<div className="server-card server-card--active">
{/* toggle — виден на мобильном вместо чипа */}
<div
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`}
aria-hidden="true"
>
<div className="server-toggle-thumb" />
</div>
{/* чип онлайн/офлайн — виден на десктопе */}
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}>
<span className="chip-dot" />
</span>
<div className="server-card-info">
<span className="server-card-name">{serverInfo.name}</span>
<span className="server-card-ip">{serverInfo.ip}</span>
</div>
<span className="server-card-peers">{serverInfo.peers} peers</span>
</div>
)}
{showAddServer && (
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
<button className="server-add" disabled>
<IcoPlus /> Добавить сервер
</button>
</span>
)}
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && (
<button
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
onClick={onRestartAwg}
disabled={restarting}
>
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
</button>
)}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
// Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда
// её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json).
export const IcoMenu = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
);
export const IcoUsers = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
);
export const IcoKey = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7.5" cy="15.5" r="4.5"/>
<path d="M10.7 12.3 21 2"/><path d="M16 7l3 3"/><path d="M18 5l2 2"/>
</svg>
);
export const IcoPlus = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
);
export const IcoRefresh = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
);
export const IcoQR = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<path d="M14 14h1v1h-1zM18 14h1v1h-1zM14 18h1v1h-1zM18 18h1v1h-1zM16 16h1v1h-1z"/>
</svg>
);
export const IcoTrash = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
</svg>
);
export const IcoLogout = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
);
export const IcoGlobe = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
);
export const IcoCopy = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
);
export const IcoSun = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
);
export const IcoMoon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
);
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
export const ICONS: Record<string, () => JSX.Element> = {
users: IcoUsers,
key: IcoKey,
};
+91
View File
@@ -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<any> {
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
return r.data;
}
export async function vpnKeyToConf(vpnKey: string): Promise<string> {
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) + ' ч';
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
+72
View File
@@ -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; }
}
+145
View File
@@ -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<ApiKey[]>([]);
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). */}
<div className="toolbar">
<input
className="field"
placeholder="Метка ключа (напр. ci-bot)"
value={label}
onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createKey()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createKey}>
<IcoPlus /> Создать ключ
</button>
</div>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<tr key={k.id}>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
<div className="apikey-cards-list">
<p className="apikey-count">{keys.length} ключей</p>
{keys.length === 0 ? (
<p className="apikey-empty">Нет ключей</p>
) : keys.map((k, i) => (
<div className="apikey-card" key={k.id}>
<div className="apikey-card-header">
<div className="apikey-card-title">
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</div>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
</div>
</div>
))}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">Ключ «{created.label}» создан</p>
<p className="qr-hint">
Скопируй ключ сейчас он показывается один раз и больше не будет доступен.
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"order": 2
}
+88
View File
@@ -0,0 +1,88 @@
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.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<PageProps>;
}
const metas = import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true });
const pages = import.meta.glob<{ default: ComponentType<PageProps> }>('./*/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<string, unknown>;
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 вкладок должны быть уникальны`);
}
+230
View File
@@ -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<User[]>([]);
const [newName, setNewName] = useState('');
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
const statsRef = useRef<ReturnType<typeof setInterval> | 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 (
<>
<div className="toolbar">
<input
className="field"
placeholder="Имя пользователя"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createUser()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createUser}>
<IcoPlus /> Создать
</button>
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
<IcoRefresh /> Обновить
</button>
</div>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Статус</th>
<th>Имя</th>
<th>IP</th>
<th> rx</th>
<th> tx</th>
<th></th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr>
) : users.map((u, i) => (
<tr key={u.name}>
<td className="td-num">{i + 1}</td>
<td>
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{u.online
? 'онлайн'
: u.lastHandshake
? timeAgo(u.lastHandshake) + ' назад'
: 'никогда'}
</span>
</td>
<td>{u.name}</td>
<td className="td-mono">{u.ip}</td>
<td>{bytes(u.rx)}</td>
<td>{bytes(u.tx)}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon"
title="QR-код vpn://"
onClick={() => showQR(u.name)}
><IcoQR /></button>
<button
className="btn-icon btn-icon--danger"
title="Удалить"
onClick={() => deleteUser(u.name)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="user-cards-list">
<p className="users-count">{users.length} пользователей</p>
{users.length === 0 ? (
<p className="empty-cards">Нет пользователей</p>
) : users.map((u, i) => (
<div className="user-card" key={u.name}>
<div className="user-card-header">
<div className="user-card-title">
<span className="user-card-num">#{i + 1}</span>
<span className="user-card-name">{u.name}</span>
</div>
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{u.online
? 'онлайн'
: u.lastHandshake
? timeAgo(u.lastHandshake) + ' назад'
: 'никогда'}
</span>
</div>
<div className="user-card-ip">
<IcoGlobe />
<span className="user-card-ip-text">{u.ip}</span>
</div>
<div className="user-card-metrics">
<div className="metric-block">
<span className="metric-label"> rx</span>
<span className="metric-value">{bytes(u.rx)}</span>
</div>
<div className="metric-block">
<span className="metric-label"> tx</span>
<span className="metric-value">{bytes(u.tx)}</span>
</div>
</div>
<div className="user-card-actions">
<button
className="btn btn--outline"
aria-label="QR-код"
onClick={() => showQR(u.name)}
><IcoQR /> QR</button>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить"
onClick={() => deleteUser(u.name)}
><IcoTrash /></button>
</div>
</div>
))}
</div>
{qrModal && (
<div className="qr-backdrop" onClick={() => setQrModal(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">{qrModal.name}</p>
<img
className="qr-img"
src={qrModal.dataUrl}
alt="QR"
title="Нажми чтобы скопировать vpn:// ключ"
onClick={async () => { await copyText(qrModal.vpnKey); showMsg('Ключ скопирован'); }}
/>
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
<div className="qr-actions">
<button className="btn btn--tonal" onClick={async () => {
try {
const conf = await vpnKeyToConf(qrModal.vpnKey);
const safe = qrModal.name.replace(/[\/\\:*?"<>|]+/g, '_').trim() || 'awg';
downloadFile(safe + '.conf', conf);
showMsg('Конфиг скачан');
} catch { showMsg('Не удалось декодировать ключ'); }
}}>Скачать .conf</button>
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "users",
"label": "Пользователи",
"icon": "users",
"order": 1
}
+70
View File
@@ -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; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+15
View File
@@ -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"]
}
+14
View File
@@ -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',
},
},
});
+12
View File
@@ -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"
}
}
+363
View File
@@ -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<void>(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<boolean> {
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<boolean> {
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<string>(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<string> =>
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 <start|stop|restart|status|credentials [user|pass]>";
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); });
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}
+624
View File
@@ -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" <<UNIT
[Unit]
Description=AWG Control — awg-ctrl + awg-ui (Forgetting)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=PATH=${node_dir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStartPre=/bin/sh -c 'awg show ${IFACE} >/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" <<CONF
[Interface]
Address = ${SUBNET}.0.1/16
MTU = ${MTU}
PostUp = ${AWG_BIN} set ${IFACE} private-key ${PRIV_KEY_FILE}; iptables -A FORWARD -i ${IFACE} -j ACCEPT; iptables -t nat -A POSTROUTING -o ${NET_IFACE} -j MASQUERADE
PreDown = iptables -D FORWARD -i ${IFACE} -j ACCEPT; iptables -t nat -D POSTROUTING -o ${NET_IFACE} -j MASQUERADE
ListenPort = ${AWG_PORT}
PrivateKey = ${PRIV_KEY}
Jc = ${JC}
Jmin = ${JMIN}
Jmax = ${JMAX}
S1 = ${S1}
S2 = ${S2}
S3 = ${S3}
S4 = ${S4}
H1 = ${H1}
H2 = ${H2}
H3 = ${H3}
H4 = ${H4}
CONF
chmod 600 "$AWG_CONF"
ok "$AWG_CONF"
step "5/7 Запуск AWG"
if awg show "$IFACE" &>/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" <<ENV
# Generated by install.sh — $(date -u '+%Y-%m-%d %H:%M UTC')
# ── awgctrl (Ring 0) ──────────────────────────────────────────────────────
AWGCTRL_PORT=${AWGCTRL_PORT}
SERVER_IP=${SERVER_IP}
SERVER_PORT=${AWG_PORT}
SERVER_NAME=${SERVER_NAME}
# ── внутренняя авторизация awg-ui → awg-ctrl (Ed25519) ─────────────────────
INTERNAL_AUTH_KEY_FILE=${INTERNAL_AUTH_PRIV}
INTERNAL_AUTH_PUB_FILE=${INTERNAL_AUTH_PUB}
# ── ui (Ring 4) ───────────────────────────────────────────────────────────
UI_PORT=${UI_PORT}
UI_USER=${UI_USER}
UI_PASS=${UI_PASS}
JWT_SECRET=${JWT_SECRET}
ENV
chmod 600 "$PROJECT/cli/cli.env"
ok "$PROJECT/cli/cli.env"
start_and_status
echo
echo -e "${BLD}${GRN}✓ Установка завершена${NC}"
echo
echo -e " ${BLD}Сервисы:${NC}"
echo " awg-ctrl → http://localhost:${AWGCTRL_PORT} (внутренний)"
echo " awg-ui → http://${SERVER_IP}:${UI_PORT}"
echo
echo -e " ${BLD}${YLW}UI логин:${NC} ${UI_USER}"
echo -e " ${BLD}${YLW}UI пароль:${NC} ${UI_PASS}"
echo
echo -e " ${BLD}Управление:${NC}"
echo " awg-ctrl — CLI: start/stop/status/credentials"
echo " systemctl start|stop awg-control — сервис (автозапуск на загрузке включён)"
echo