mirror of
https://github.com/maeneko/forgetting.git
synced 2026-08-25 15:24:26 +00:00
Initial commit
This commit is contained in:
+22
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2016",
|
||||||
|
"module": "commonjs",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=/"></head></html>
|
||||||
@@ -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}`); });
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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) + ' ч';
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "apikeys",
|
||||||
|
"label": "API-ключи",
|
||||||
|
"icon": "key",
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
@@ -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 вкладок должны быть уникальны`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "users",
|
||||||
|
"label": "Пользователи",
|
||||||
|
"icon": "users",
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); });
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2016",
|
||||||
|
"module": "commonjs",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
+624
@@ -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
|
||||||
Reference in New Issue
Block a user