Compare commits
5
Commits
v0.4.1
...
7019490e04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7019490e04 | ||
|
|
08908b002c | ||
|
|
6e3959d16c | ||
|
|
fd87884bde | ||
|
|
4971a90e41 |
@@ -15,7 +15,7 @@
|
||||
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
|
||||
|
||||
## Требования
|
||||
- **ОС**: Ubuntu 24.04+ (Тесты проходили на Ubuntu 24.04.4 LTS)
|
||||
- **ОС**: Ubuntu 24.04+ (Тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и переопределяется через `AMNEZIA_PPA_SUITE`)
|
||||
- **Права**: root.
|
||||
- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG).
|
||||
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
|
||||
@@ -115,7 +115,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
||||
|
||||
| Метод | Ответ |
|
||||
|---|---|
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.2.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||
|
||||
|
||||
+175
-5
@@ -87,6 +87,20 @@ db.exec(`
|
||||
)
|
||||
`);
|
||||
|
||||
// Пиры устройств sen://-подписки. Отдельная таблица, users не трогаем: у этих
|
||||
// пиров нет ни vpn_key, ни имени — приватный ключ живёт только на устройстве,
|
||||
// сюда приходит лишь публичный. owner — непрозрачная метка от awg-ui
|
||||
// («m<masterId>/d<deviceId>»), нужна только для комментария в conf и логов.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS peers (
|
||||
pub_key TEXT PRIMARY KEY,
|
||||
ip TEXT NOT NULL UNIQUE,
|
||||
psk_key TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
const cfgStmts = {
|
||||
get: db.prepare<[string], { value: string }>("SELECT value FROM config WHERE key = ?"),
|
||||
set: db.prepare<[string, string]>("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"),
|
||||
@@ -276,12 +290,27 @@ interface UserRow {
|
||||
vpn_key_prev: string;
|
||||
}
|
||||
|
||||
interface PeerRow {
|
||||
pub_key: string;
|
||||
ip: string;
|
||||
psk_key: string;
|
||||
owner: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const stmts = {
|
||||
peersAll: db.prepare<[], PeerRow>("SELECT * FROM peers ORDER BY created_at, rowid"),
|
||||
peerGet: db.prepare<[string], PeerRow>("SELECT * FROM peers WHERE pub_key = ?"),
|
||||
peerInsert: db.prepare<[string, string, string, string, number]>("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)"),
|
||||
peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"),
|
||||
peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"),
|
||||
peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"),
|
||||
userPubTaken: db.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?"),
|
||||
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
|
||||
all: db.prepare<[], UserRow>("SELECT * FROM users"),
|
||||
insert: db.prepare<[string, string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)"),
|
||||
delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"),
|
||||
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"),
|
||||
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users UNION SELECT ip FROM peers"),
|
||||
// Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут
|
||||
// смениться, если исходный ключ не удалось разобрать.
|
||||
reissue: db.prepare<[string, string, string, string, string]>(
|
||||
@@ -547,17 +576,22 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
|
||||
|
||||
function rebuildConf() {
|
||||
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
|
||||
const devices = stmts.peersAll.all();
|
||||
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}`
|
||||
// Без записей в peers вывод байт-в-байт прежний.
|
||||
const peers = [
|
||||
...users.map(u => ({ label: u.name, ...u })),
|
||||
...devices.map(d => ({ label: d.owner || "device", ...d })),
|
||||
].map(u =>
|
||||
`\n# ${u.label}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}`
|
||||
).join("\n");
|
||||
|
||||
writeFileSync(confFile, iface + "\n" + peers + "\n");
|
||||
logger.info("conf rebuilt", { peers: users.length });
|
||||
logger.info("conf rebuilt", { peers: users.length + devices.length });
|
||||
}
|
||||
|
||||
const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key";
|
||||
@@ -829,6 +863,89 @@ function removeUser(username: string) {
|
||||
logger.info("user removed", { name: username });
|
||||
}
|
||||
|
||||
// ── Пиры устройств (sen://) ────────────────────────────────────────────────
|
||||
// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим
|
||||
// публичным ключом». Приватного ключа устройства здесь нет и быть не может.
|
||||
class HttpError extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
}
|
||||
|
||||
// Публичный ключ WireGuard: base64 от 32 байт (44 символа, последний «=»).
|
||||
function checkPubKey(k: unknown): string {
|
||||
if (typeof k !== "string" || !/^[A-Za-z0-9+/]{43}=$/.test(k) || Buffer.from(k, "base64").length !== 32)
|
||||
throw new HttpError(400, "Неверный публичный ключ");
|
||||
return k;
|
||||
}
|
||||
|
||||
// В пути base64url: «/» и «+» обычного base64 в URL ломают маршрут.
|
||||
function pubKeyFromParam(p: string): string {
|
||||
return checkPubKey(Buffer.from(p, "base64url").toString("base64"));
|
||||
}
|
||||
|
||||
function assertPubKeyFree(pub: string) {
|
||||
if (stmts.peerGet.get(pub) || stmts.userPubTaken.get(pub)!.n > 0 || pub === getServerPublicKey())
|
||||
throw new HttpError(409, "Публичный ключ уже используется");
|
||||
}
|
||||
|
||||
function addPeer(pubKey: string, owner: string): { ip: string; psk_key: string } {
|
||||
checkPubKey(pubKey);
|
||||
const psk = run("awg genpsk");
|
||||
const ip = db.transaction(() => {
|
||||
assertPubKeyFree(pubKey);
|
||||
const ip = nextIp();
|
||||
stmts.peerInsert.run(pubKey, ip, psk, owner, Math.floor(Date.now() / 1000));
|
||||
return ip;
|
||||
})();
|
||||
|
||||
const r = setPeer(pubKey, psk, ip);
|
||||
if (r.status !== 0) {
|
||||
stmts.peerDelete.run(pubKey);
|
||||
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||
}
|
||||
rebuildConf();
|
||||
logger.info("peer added", { owner, ip });
|
||||
return { ip, psk_key: psk };
|
||||
}
|
||||
|
||||
// Смена ключа устройства: IP и PSK остаются, меняется только pub_key.
|
||||
function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: string } {
|
||||
checkPubKey(newPub);
|
||||
const old = stmts.peerGet.get(oldPub);
|
||||
if (!old) throw new HttpError(404, "Пир не найден");
|
||||
db.transaction(() => { assertPubKeyFree(newPub); stmts.peerRekey.run(newPub, oldPub); })();
|
||||
|
||||
const r = setPeer(newPub, old.psk_key, old.ip);
|
||||
if (r.status !== 0) {
|
||||
stmts.peerRekey.run(oldPub, newPub);
|
||||
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||
}
|
||||
spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]);
|
||||
rebuildConf();
|
||||
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
|
||||
return { ip: old.ip, psk_key: old.psk_key };
|
||||
}
|
||||
|
||||
function rotatePeerPsk(pub: string): { ip: string; psk_key: string } {
|
||||
const peer = stmts.peerGet.get(pub);
|
||||
if (!peer) throw new HttpError(404, "Пир не найден");
|
||||
const psk = run("awg genpsk");
|
||||
const r = setPeer(pub, psk, peer.ip);
|
||||
if (r.status !== 0) throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||
stmts.peerPsk.run(psk, pub);
|
||||
rebuildConf();
|
||||
logger.info("peer psk rotated", { owner: peer.owner });
|
||||
return { ip: peer.ip, psk_key: psk };
|
||||
}
|
||||
|
||||
function removePeer(pub: string) {
|
||||
const peer = stmts.peerGet.get(pub);
|
||||
if (!peer) throw new HttpError(404, "Пир не найден");
|
||||
stmts.peerDelete.run(pub);
|
||||
spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
|
||||
rebuildConf();
|
||||
logger.info("peer removed", { owner: peer.owner });
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1kb" }));
|
||||
|
||||
@@ -863,7 +980,10 @@ function validateName(req: Request, res: Response, next: NextFunction) {
|
||||
function handler(fn: (req: Request, res: Response) => void | Promise<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" }); }
|
||||
catch (e) {
|
||||
if (e instanceof HttpError) { res.status(e.status).json({ error: e.message }); return; }
|
||||
logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -931,6 +1051,56 @@ app.delete("/api/users/:name", auth, validateName, handler((req, res) => {
|
||||
res.json({ success: true, name: req.params.name });
|
||||
}));
|
||||
|
||||
app.post("/api/peers", auth, handler((req, res) => {
|
||||
const { pub_key, owner } = (req.body ?? {}) as { pub_key?: string; owner?: string };
|
||||
if (typeof owner !== "string" || !/^[\w/.-]{0,64}$/.test(owner)) throw new HttpError(400, "Неверный owner");
|
||||
res.status(201).json(addPeer(checkPubKey(pub_key), owner));
|
||||
}));
|
||||
|
||||
app.get("/api/peers", auth, handler((_req, res) => {
|
||||
const stats = getPeersData();
|
||||
res.json({
|
||||
peers: stmts.peersAll.all().map(p => ({
|
||||
pub_key: p.pub_key, ip: p.ip, psk_key: p.psk_key, owner: p.owner,
|
||||
online: stats[p.pub_key]?.online ?? false,
|
||||
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
|
||||
rx: stats[p.pub_key]?.rx ?? 0,
|
||||
tx: stats[p.pub_key]?.tx ?? 0,
|
||||
})),
|
||||
});
|
||||
}));
|
||||
|
||||
app.put("/api/peers/:pub", auth, handler((req, res) => {
|
||||
const { pub_key } = (req.body ?? {}) as { pub_key?: string };
|
||||
res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key)));
|
||||
}));
|
||||
|
||||
app.post("/api/peers/:pub/psk", auth, handler((req, res) => {
|
||||
res.json(rotatePeerPsk(pubKeyFromParam(req.params.pub)));
|
||||
}));
|
||||
|
||||
app.delete("/api/peers/:pub", auth, handler((req, res) => {
|
||||
removePeer(pubKeyFromParam(req.params.pub));
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf;
|
||||
// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf.
|
||||
app.get("/api/profile", auth, handler((_req, res) => {
|
||||
const awg: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(CONFIG.awgParams)) if (String(v) !== "") awg[k] = String(v);
|
||||
res.json({
|
||||
name: CONFIG.serverName,
|
||||
endpoint: `${CONFIG.serverIp}:${CONFIG.serverPort}`,
|
||||
server_pub: getServerPublicKey(),
|
||||
gen: CONFIG.gen,
|
||||
dns: [CONFIG.dns1, CONFIG.dns2],
|
||||
keepalive: CONFIG.keepalive,
|
||||
mtu: CONFIG.mtu,
|
||||
awg,
|
||||
});
|
||||
}));
|
||||
|
||||
app.post("/awg/restart", auth, handler((_req, res) => {
|
||||
restartAwg();
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
//
|
||||
// Локальный мок для разработки фронтенда: эмулирует awg-ui + awg-ctrl целиком в
|
||||
// памяти, AWG не нужен. Логин admin / admin. Запуск: npm run dev:mock.
|
||||
import express from "express";
|
||||
import crypto from "crypto";
|
||||
import { encodeSenLink } from "./sen";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const rnd = (n: number) => Math.floor(Math.random() * n);
|
||||
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
|
||||
const GEN = "3.1";
|
||||
|
||||
// ── пользователи (vpn://) ──
|
||||
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
|
||||
const users: U[] = ["alice", "bob", "laptop-ivan"].map((name, i) => ({
|
||||
name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"),
|
||||
key_gen: GEN, online: i !== 1, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
|
||||
}));
|
||||
|
||||
// ── API-ключи ──
|
||||
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }];
|
||||
let apiId = 1;
|
||||
|
||||
// ── мастер-ключи sen:// ──
|
||||
interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number }
|
||||
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[] }
|
||||
let mkId = 1, devId = 1;
|
||||
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
|
||||
const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")] }];
|
||||
const signPub = crypto.randomBytes(32);
|
||||
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, server_id: 0, created_at: m.created_at });
|
||||
const find = (id: string) => masters.find(m => m.id === Number(id));
|
||||
|
||||
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
|
||||
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
|
||||
app.post("/logout", (_q, r) => r.json({ ok: true }));
|
||||
app.get("/health", (_q, r) => r.json({ status: "ok", server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, awg: { status: "ok", peers: users.length, module: "3.0.1", tools: "1.0.0" } }));
|
||||
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
|
||||
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
|
||||
|
||||
app.get("/api/users", (_q, r) => r.json({ users }));
|
||||
app.get("/api/users/stats", (_q, r) => r.json({ users: users.map(({ vpn_key, pub_key, ...u }) => u) }));
|
||||
app.post("/api/users/reissue", (_q, r) => r.json({ total: users.length, reissued: users.length, regenerated: [], backup: "" }));
|
||||
app.post("/api/users", (q, r) => {
|
||||
const name = String(q.body.name ?? "");
|
||||
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" });
|
||||
if (users.some(u => u.name === name)) return r.status(409).json({ error: "Пользователь уже существует" });
|
||||
const u: U = { name, ip: `10.9.0.${users.length + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), key_gen: GEN, online: false, lastHandshake: 0, rx: 0, tx: 0 };
|
||||
users.push(u); r.status(201).json(u);
|
||||
});
|
||||
app.post("/api/users/:name", (q, r) => { const u = users.find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); });
|
||||
app.delete("/api/users/:name", (q, r) => { const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
|
||||
|
||||
app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys }));
|
||||
app.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
|
||||
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
|
||||
|
||||
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
|
||||
app.post("/ui/masterkeys", (q, r) => {
|
||||
const limit = q.body.device_limit ?? 3;
|
||||
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
|
||||
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [] };
|
||||
masters.push(m); r.status(201).json(mkOut(m));
|
||||
});
|
||||
app.patch("/ui/masterkeys/:id", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m)); });
|
||||
app.post("/ui/masterkeys/:id/rotate", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.secret = crypto.randomBytes(16); r.json({ id: m.id }); });
|
||||
app.post("/ui/masterkeys/:id/rekey", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.devs.forEach(d => d.rekey_requested = true); r.json({ id: m.id }); });
|
||||
app.delete("/ui/masterkeys/:id", (q, r) => { const i = masters.findIndex(m => m.id === Number(q.params.id)); if (i >= 0) masters.splice(i, 1); r.json({ success: true }); });
|
||||
app.get("/ui/masterkeys/:id/link", (q, r) => {
|
||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
|
||||
});
|
||||
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs }) : r.status(404).json({ error: "Не найден" }); });
|
||||
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
|
||||
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
|
||||
app.post("/ui/devices/:id/psk", (_q, r) => r.json({}));
|
||||
|
||||
app.listen(8080, () => console.log("mock: http://localhost:8080 (login admin / admin)"));
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
//
|
||||
// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1.
|
||||
// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент
|
||||
// реализуется по ней, поэтому любое изменение здесь — изменение протокола.
|
||||
import crypto from "crypto";
|
||||
|
||||
export const SEN_PREFIX = "sen://";
|
||||
export const SEN_VERSION = 1;
|
||||
export const FLAG_TLS = 0x01;
|
||||
|
||||
const ADDR_V4 = 0x04;
|
||||
const ADDR_V6 = 0x06;
|
||||
const ADDR_DOMAIN = 0x44;
|
||||
|
||||
export class SenLinkError extends Error {}
|
||||
|
||||
export interface SenAddr { host: string; port: number }
|
||||
|
||||
export interface SenLink {
|
||||
tls: boolean;
|
||||
addrs: SenAddr[];
|
||||
secret: Buffer; // 16 байт
|
||||
signPub: Buffer; // 32 байта, Ed25519
|
||||
tlsPin?: Buffer; // 32 байта, только при tls
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ── CRC32 (IEEE) ───────────────────────────────────────────────────────────
|
||||
// zlib.crc32 появился только в Node 20.15, а целимся в 20.x целиком.
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
export function crc32(buf: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
// ── Адреса ─────────────────────────────────────────────────────────────────
|
||||
function ipv6ToBytes(host: string): Buffer | null {
|
||||
const parts = host.split("::");
|
||||
if (parts.length > 2) return null;
|
||||
const head = parts[0] ? parts[0].split(":") : [];
|
||||
const tail = parts.length === 2 && parts[1] ? parts[1].split(":") : [];
|
||||
const missing = 8 - head.length - tail.length;
|
||||
if (parts.length === 1 ? head.length !== 8 : missing < 1) return null;
|
||||
const groups = [...head, ...Array(parts.length === 2 ? missing : 0).fill("0"), ...tail];
|
||||
if (groups.length !== 8) return null;
|
||||
const out = Buffer.alloc(16);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
|
||||
out.writeUInt16BE(parseInt(groups[i], 16), i * 2);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToIpv6(b: Buffer): string {
|
||||
const g: string[] = [];
|
||||
for (let i = 0; i < 16; i += 2) g.push(b.readUInt16BE(i).toString(16));
|
||||
// Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи.
|
||||
let bestStart = -1, bestLen = 0;
|
||||
for (let i = 0; i < 8;) {
|
||||
if (g[i] !== "0") { i++; continue; }
|
||||
let j = i;
|
||||
while (j < 8 && g[j] === "0") j++;
|
||||
if (j - i > bestLen) { bestStart = i; bestLen = j - i; }
|
||||
i = j;
|
||||
}
|
||||
if (bestLen < 2) return g.join(":");
|
||||
return g.slice(0, bestStart).join(":") + "::" + g.slice(bestStart + bestLen).join(":");
|
||||
}
|
||||
|
||||
function encodeAddr({ host, port }: SenAddr): Buffer {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new SenLinkError(`Неверный порт: ${port}`);
|
||||
const p = Buffer.alloc(2);
|
||||
p.writeUInt16BE(port);
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
||||
const o = host.split(".").map(Number);
|
||||
if (o.some(n => n > 255)) throw new SenLinkError(`Неверный IPv4: ${host}`);
|
||||
return Buffer.concat([Buffer.from([ADDR_V4, ...o]), p]);
|
||||
}
|
||||
if (host.includes(":")) {
|
||||
const b = ipv6ToBytes(host);
|
||||
if (!b) throw new SenLinkError(`Неверный IPv6: ${host}`);
|
||||
return Buffer.concat([Buffer.from([ADDR_V6]), b, p]);
|
||||
}
|
||||
const h = Buffer.from(host, "utf8");
|
||||
if (h.length < 1 || h.length > 253 || !/^[A-Za-z0-9.-]+$/.test(host))
|
||||
throw new SenLinkError(`Неверный домен: ${host}`);
|
||||
return Buffer.concat([Buffer.from([ADDR_DOMAIN, h.length]), h, p]);
|
||||
}
|
||||
|
||||
// ── Ссылка ─────────────────────────────────────────────────────────────────
|
||||
export function encodeSenLink(l: SenLink): string {
|
||||
if (l.addrs.length < 1 || l.addrs.length > 3) throw new SenLinkError("Адресов должно быть от 1 до 3");
|
||||
if (l.secret.length !== 16) throw new SenLinkError("secret: 16 байт");
|
||||
if (l.signPub.length !== 32) throw new SenLinkError("signPub: 32 байта");
|
||||
if (l.tls && l.tlsPin?.length !== 32) throw new SenLinkError("tlsPin: 32 байта при TLS");
|
||||
const name = Buffer.from(l.name, "utf8");
|
||||
if (name.length > 64) throw new SenLinkError("Имя: до 64 байт");
|
||||
|
||||
const body = Buffer.concat([
|
||||
Buffer.from([SEN_VERSION, l.tls ? FLAG_TLS : 0, l.addrs.length]),
|
||||
...l.addrs.map(encodeAddr),
|
||||
l.secret,
|
||||
l.signPub,
|
||||
...(l.tls ? [l.tlsPin as Buffer] : []),
|
||||
Buffer.from([name.length]),
|
||||
name,
|
||||
]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body));
|
||||
return SEN_PREFIX + Buffer.concat([body, crc]).toString("base64url");
|
||||
}
|
||||
|
||||
export function decodeSenLink(link: string): SenLink {
|
||||
const t = link.trim();
|
||||
if (!t.toLowerCase().startsWith(SEN_PREFIX)) throw new SenLinkError("Ссылка должна начинаться с sen://");
|
||||
const payload = t.slice(SEN_PREFIX.length).replace(/\s+/g, "");
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(payload)) throw new SenLinkError("Ссылка повреждена: недопустимые символы");
|
||||
const raw = Buffer.from(payload, "base64url");
|
||||
if (raw.length < 3 + 4) throw new SenLinkError("Ссылка слишком короткая");
|
||||
|
||||
const body = raw.subarray(0, raw.length - 4);
|
||||
if (raw.readUInt32BE(raw.length - 4) !== crc32(body)) throw new SenLinkError("Ссылка повреждена: не сошлась контрольная сумма");
|
||||
|
||||
let off = 0;
|
||||
const need = (n: number) => { if (off + n > body.length) throw new SenLinkError("Ссылка обрезана"); };
|
||||
const take = (n: number) => { need(n); const s = body.subarray(off, off + n); off += n; return s; };
|
||||
|
||||
const version = take(1)[0];
|
||||
if (version !== SEN_VERSION) throw new SenLinkError(`Неизвестная версия ссылки: ${version}`);
|
||||
const flags = take(1)[0];
|
||||
if (flags & ~FLAG_TLS) throw new SenLinkError("Неизвестные флаги ссылки");
|
||||
const n = take(1)[0];
|
||||
if (n < 1 || n > 3) throw new SenLinkError("Неверное число адресов");
|
||||
|
||||
const addrs: SenAddr[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const type = take(1)[0];
|
||||
let host: string;
|
||||
if (type === ADDR_V4) host = [...take(4)].join(".");
|
||||
else if (type === ADDR_V6) host = bytesToIpv6(Buffer.from(take(16)));
|
||||
else if (type === ADDR_DOMAIN) {
|
||||
const len = take(1)[0];
|
||||
if (len < 1) throw new SenLinkError("Пустой домен");
|
||||
host = Buffer.from(take(len)).toString("utf8");
|
||||
if (!/^[A-Za-z0-9.-]+$/.test(host)) throw new SenLinkError("Неверный домен");
|
||||
} else throw new SenLinkError(`Неизвестный тип адреса: ${type}`);
|
||||
const port = Buffer.from(take(2)).readUInt16BE(0);
|
||||
if (port === 0) throw new SenLinkError("Порт 0");
|
||||
addrs.push({ host, port });
|
||||
}
|
||||
|
||||
const secret = Buffer.from(take(16));
|
||||
const signPub = Buffer.from(take(32));
|
||||
const tls = (flags & FLAG_TLS) !== 0;
|
||||
const tlsPin = tls ? Buffer.from(take(32)) : undefined;
|
||||
const nameLen = take(1)[0];
|
||||
if (nameLen > 64) throw new SenLinkError("Имя длиннее 64 байт");
|
||||
const name = Buffer.from(take(nameLen)).toString("utf8");
|
||||
if (off !== body.length) throw new SenLinkError("Лишние данные в конце ссылки");
|
||||
|
||||
return { tls, addrs, secret, signPub, tlsPin, name };
|
||||
}
|
||||
|
||||
// ── Ключи Ed25519 в «сыром» виде (32 байта) ────────────────────────────────
|
||||
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
|
||||
export function rawPublicKey(key: crypto.KeyObject): Buffer {
|
||||
const der = key.export({ type: "spki", format: "der" }) as Buffer;
|
||||
return der.subarray(der.length - 32);
|
||||
}
|
||||
|
||||
export function publicKeyFromRaw(raw: Buffer): crypto.KeyObject {
|
||||
if (raw.length !== 32) throw new SenLinkError("Публичный ключ Ed25519: 32 байта");
|
||||
return crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" });
|
||||
}
|
||||
|
||||
/** SHA-256 от SPKI DER сертификата — то, что кладётся в ссылку как pin. */
|
||||
export function spkiPin(certPem: string): Buffer {
|
||||
const spki = new crypto.X509Certificate(certPem).publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
||||
return crypto.createHash("sha256").update(spki).digest();
|
||||
}
|
||||
|
||||
// ── Подписи протокола ──────────────────────────────────────────────────────
|
||||
// Ответ: {"body": "<JSON-строка>", "sig": "<base64url>"}; sig — Ed25519 над
|
||||
// UTF-8 байтами строки body. body — именно строка, а не вложенный объект,
|
||||
// чтобы клиенту не нужна была каноническая сериализация JSON.
|
||||
export function signResponse(payload: unknown, signKey: crypto.KeyObject): { body: string; sig: string } {
|
||||
const body = JSON.stringify(payload);
|
||||
return { body, sig: crypto.sign(null, Buffer.from(body, "utf8"), signKey).toString("base64url") };
|
||||
}
|
||||
|
||||
export function verifyResponse(res: { body: string; sig: string }, signPub: Buffer): boolean {
|
||||
try {
|
||||
return crypto.verify(null, Buffer.from(res.body, "utf8"), publicKeyFromRaw(signPub), Buffer.from(res.sig, "base64url"));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Запрос: подпись ключом устройства (auth_pub) над строкой
|
||||
// METHOD \n /path \n ts \n hex(sha256(тело запроса))
|
||||
// Тело пустое → sha256 пустой строки. path — без query.
|
||||
export function requestSigString(method: string, urlPath: string, ts: number, body: Buffer | string): string {
|
||||
const h = crypto.createHash("sha256").update(body).digest("hex");
|
||||
return `${method.toUpperCase()}\n${urlPath}\n${ts}\n${h}`;
|
||||
}
|
||||
|
||||
export function signRequest(
|
||||
method: string, urlPath: string, ts: number, body: Buffer | string, authKey: crypto.KeyObject,
|
||||
): string {
|
||||
return crypto.sign(null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"), authKey).toString("base64url");
|
||||
}
|
||||
|
||||
export function verifyRequest(
|
||||
method: string, urlPath: string, ts: number, body: Buffer | string, sig: string, authPub: Buffer,
|
||||
): boolean {
|
||||
try {
|
||||
return crypto.verify(
|
||||
null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"),
|
||||
publicKeyFromRaw(authPub), Buffer.from(sig, "base64url"),
|
||||
);
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import Database from "better-sqlite3";
|
||||
import { createSub } from "./sub";
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
@@ -279,6 +280,12 @@ ext.delete("/users/:name", wrap(async (req, res) => {
|
||||
// его и потребует JWT вместо ключа.
|
||||
app.use("/api/v1", ext);
|
||||
|
||||
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
|
||||
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
|
||||
const sub = createSub({ uidb, ctrl, baseDir: __dirname });
|
||||
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
|
||||
app.use("/ui/devices", requireAuth, sub.devices);
|
||||
|
||||
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
|
||||
|
||||
app.use(express.static(path.join(__dirname, "dist")));
|
||||
@@ -288,3 +295,4 @@ app.get("*", (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
|
||||
sub.start();
|
||||
@@ -24,6 +24,7 @@ html, body, #root { height: 100%; }
|
||||
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
|
||||
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
|
||||
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
|
||||
--warn-dot: #e08a2c;
|
||||
|
||||
--danger-border: #e6c4bc;
|
||||
--danger-text: #b34a3a;
|
||||
@@ -65,6 +66,7 @@ html, body, #root { height: 100%; }
|
||||
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
|
||||
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
|
||||
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
|
||||
--warn-dot: #ffb76b;
|
||||
|
||||
--danger-border: #6e4540;
|
||||
--danger-text: #ffb4ab;
|
||||
|
||||
@@ -35,6 +35,13 @@ export const IcoRefresh = () => (
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
);
|
||||
export const IcoSettings = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>
|
||||
<circle cx="9" cy="7" r="2.5" fill="var(--surface-bright, transparent)"/>
|
||||
<circle cx="15" cy="17" r="2.5" fill="var(--surface-bright, transparent)"/>
|
||||
</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"/>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { apiFetch, copyText, bytes, timeAgo, genLabel, type AwgGen, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
||||
import './masterkeys.css';
|
||||
|
||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
||||
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; created_at: number }
|
||||
interface Device {
|
||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
|
||||
}
|
||||
|
||||
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
|
||||
const platformLabel = (d: Device): string =>
|
||||
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
|
||||
|
||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||
|
||||
export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
const [keys, setKeys] = useState<MasterKey[]>([]);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [tls, setTls] = useState(false);
|
||||
const [server, setServer] = useState<{ name: string; ip: string; gen: string } | null>(null);
|
||||
const [label, setLabel] = useState('');
|
||||
const [limit, setLimit] = useState('3');
|
||||
const [devs, setDevs] = useState<Record<number, Device[]>>({});
|
||||
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
|
||||
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
|
||||
const [limitEdit, setLimitEdit] = useState('');
|
||||
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
||||
const list: MasterKey[] = d.keys ?? [];
|
||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
|
||||
// Сервер, который сейчас отдаётся по ключу (он пока единственный).
|
||||
apiFetch('GET', '/health', token).then(h => setServer({ name: h.server, ip: h.ip, gen: h.gen })).catch(() => {});
|
||||
const all = await Promise.all(list.map(k =>
|
||||
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
|
||||
setDevs(Object.fromEntries(all));
|
||||
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
|
||||
}, [token, showMsg]);
|
||||
|
||||
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
|
||||
const act = useCallback(async (fn: () => Promise<unknown>, ok: string) => {
|
||||
try { await fn(); showMsg(ok); await load(); }
|
||||
catch (e) { showMsg(errText(e)); }
|
||||
}, [load, showMsg]);
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
await act(async () => {
|
||||
await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit) });
|
||||
setLabel('');
|
||||
}, 'Мастер-ключ создан');
|
||||
}, [label, limit, token, act]);
|
||||
|
||||
const remove = useCallback(async (k: MasterKey) => {
|
||||
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
|
||||
await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label);
|
||||
}, [token, act]);
|
||||
|
||||
const fetchLink = useCallback(async (k: MasterKey) => {
|
||||
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
|
||||
return r as { link: string; tls: boolean };
|
||||
}, [token]);
|
||||
|
||||
const showQR = useCallback(async (k: MasterKey) => {
|
||||
try {
|
||||
const { link, tls: t } = await fetchLink(k);
|
||||
const dataUrl = await QRCode.toDataURL(link, { width: 560, margin: 2, errorCorrectionLevel: 'H' });
|
||||
setModal({ label: k.label, link, tls: t, dataUrl });
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}, [fetchLink, showMsg]);
|
||||
|
||||
const copyLink = useCallback(async (k: MasterKey) => {
|
||||
try { await copyText((await fetchLink(k)).link); showMsg('Ссылка скопирована'); }
|
||||
catch (e) { showMsg(errText(e)); }
|
||||
}, [fetchLink, showMsg]);
|
||||
|
||||
const fold = (id: number) => setFolded(prev => {
|
||||
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
||||
});
|
||||
const openTools = (k: MasterKey) => { setLimitEdit(String(k.device_limit)); setTools(tools === k.id ? null : k.id); };
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
const actions = (k: MasterKey) => (
|
||||
<div className="actions">
|
||||
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
|
||||
<button className="btn-icon" title="Скопировать ссылку" aria-label="Скопировать ссылку" onClick={() => copyLink(k)}><IcoCopy /></button>
|
||||
<button className="btn-icon" title="Настройки мастер-ключа" aria-label="Настройки мастер-ключа" aria-expanded={tools === k.id} onClick={() => openTools(k)}><IcoSettings /></button>
|
||||
<button className="btn-icon btn-icon--danger" title="Удалить мастер-ключ" aria-label="Удалить мастер-ключ" onClick={() => remove(k)}><IcoTrash /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toolbox = (k: MasterKey) => (
|
||||
<div className="mk-tools">
|
||||
{/* Слева — настройки самого ключа */}
|
||||
<section className="mk-box">
|
||||
<h4 className="mk-box-title">Настройки ключа</h4>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
|
||||
<span className="mk-setting-hint">Сколько устройств может подключить ключ</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
|
||||
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
|
||||
<button className="btn btn--tonal" onClick={() =>
|
||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: Number(limitEdit) }), 'Лимит сохранён')}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<span className="mk-setting-name">Ссылка</span>
|
||||
<span className="mk-setting-hint">Старая перестанет принимать новые устройства</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<button className="btn btn--tonal" onClick={() => {
|
||||
if (confirm('Выпустить новую ссылку? Старая перестанет принимать новые устройства, уже добавленные продолжат работать.'))
|
||||
act(() => apiFetch('POST', `/ui/masterkeys/${k.id}/rotate`, token), 'Ссылка перевыпущена');
|
||||
}}>Перевыпустить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<span className="mk-setting-name">Ключи устройств</span>
|
||||
<span className="mk-setting-hint">Клиенты сменят их при следующем опросе</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<button className="btn btn--tonal" onClick={() =>
|
||||
act(() => apiFetch('POST', `/ui/masterkeys/${k.id}/rekey`, token), 'Смена ключей запрошена')}>
|
||||
Запросить смену
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён.
|
||||
TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[]
|
||||
уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */}
|
||||
<section className="mk-box">
|
||||
<h4 className="mk-box-title">Включённые серверы</h4>
|
||||
<label className="mk-server">
|
||||
<input type="checkbox" checked disabled />
|
||||
<span className="mk-server-name">{server?.name ?? 'Текущий сервер'}</span>
|
||||
{server?.ip && <code className="mk-server-ip">{server.ip}</code>}
|
||||
{server?.gen && <span className="mk-server-gen">{genLabel(server.gen as AwgGen)}</span>}
|
||||
</label>
|
||||
<span className="tip-wrap" data-tip="Появится вместе с поддержкой нескольких серверов">
|
||||
<button className="btn mk-server-add" disabled><IcoPlus /> Добавить сервер</button>
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
|
||||
const meter = (k: MasterKey, n: number) => (
|
||||
<div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : n * 3 >= k.device_limit * 2 ? ' mk-meter--warn' : ''}`}
|
||||
role="progressbar" aria-label="Устройства" aria-valuemin={0}
|
||||
aria-valuemax={k.device_limit} aria-valuenow={n}
|
||||
title={`Устройств: ${n} из ${k.device_limit}`}>
|
||||
<span className="mk-meter-text"><span>{n}</span><span>{k.device_limit}</span></span>
|
||||
<div className="mk-meter-fill"
|
||||
style={{ width: `${Math.min(100, (n / Math.max(1, k.device_limit)) * 100)}%` }}>
|
||||
{/* тот же текст поверх заливки — светлый, чтобы читался на ней */}
|
||||
<span className="mk-meter-text mk-meter-text--on" aria-hidden="true"><span>{n}</span><span>{k.device_limit}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const deviceActions = (d: Device) => (
|
||||
<div className="actions">
|
||||
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={() =>
|
||||
act(() => apiFetch('POST', `/ui/devices/${d.id}/psk`, token), 'PSK обновлён')}><IcoRefresh /></button>
|
||||
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
|
||||
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
|
||||
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
|
||||
}}><IcoTrash /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const caret = (k: MasterKey) => (
|
||||
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
||||
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
||||
onClick={() => fold(k.id)}>▾</button>
|
||||
);
|
||||
|
||||
const deviceNode = (k: MasterKey, d: Device) => (
|
||||
<li className="mk-node mk-node--device" key={d.id}>
|
||||
<div className="mk-device">
|
||||
<div className="mk-device-main">
|
||||
<span className="mk-device-title">
|
||||
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
|
||||
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
|
||||
</span>
|
||||
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
||||
{d.rekey_requested ? ' · ждёт смены ключа' : ''}</span>
|
||||
</div>
|
||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
||||
</span>
|
||||
<div className="mk-device-traffic">
|
||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
|
||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
|
||||
</div>
|
||||
{deviceActions(d)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!enabled && (
|
||||
<p className="mk-notice">Подписка не настроена на сервере: нет ключа подписи или SUB_PORT. Ссылки выдавать нельзя.</p>
|
||||
)}
|
||||
{enabled && !tls && (
|
||||
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
|
||||
)}
|
||||
|
||||
<div className="toolbar">
|
||||
<input className="field" placeholder="Метка (напр. Семья)" value={label}
|
||||
onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} />
|
||||
<input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств"
|
||||
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={create}><IcoPlus /> Создать ключ</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Десктоп: таблица, иерархия — строка мастер-ключа и устройства под ней */}
|
||||
<div className="table-card mk-table-card">
|
||||
<table className="mk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th>
|
||||
<th>↓ rx</th><th>↑ tx</th><th>Был</th><th>Устройства</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
|
||||
) : keys.map(k => {
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const open = !folded.has(k.id);
|
||||
return (
|
||||
<Fragment key={k.id}>
|
||||
<tr className="mk-tr-master">
|
||||
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
|
||||
<td colSpan={6}></td>
|
||||
<td>{meter(k, list.length)}</td>
|
||||
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
||||
</tr>
|
||||
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
|
||||
{open && list.map((d, i) => (
|
||||
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
|
||||
<td><span className="mk-indent">{d.device_name || 'Устройство'}</span></td>
|
||||
<td className="td-mono"><code className="mk-device-id" title={d.device_id}>{d.device_id}</code></td>
|
||||
<td>{platformLabel(d)}</td>
|
||||
<td>
|
||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="td-mono">{bytes(d.rx)}</td>
|
||||
<td className="td-mono">{bytes(d.tx)}</td>
|
||||
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
||||
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}</td>
|
||||
<td></td>
|
||||
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{open && free > 0 && (
|
||||
<tr className="mk-tr-free mk-last">
|
||||
<td colSpan={9}><span className="mk-indent">Свободно: {free}</span></td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */}
|
||||
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
|
||||
<ul className="mk-tree">
|
||||
{keys.map(k => {
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const isFolded = folded.has(k.id);
|
||||
return (
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
{caret(k)}
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}</span>
|
||||
{meter(k, list.length)}
|
||||
{actions(k)}
|
||||
</div>
|
||||
{tools === k.id && toolbox(k)}
|
||||
{!isFolded && (
|
||||
<ul className="mk-children">
|
||||
{list.map(d => deviceNode(k, d))}
|
||||
{free > 0 && (
|
||||
<li className="mk-node mk-node--free"><div className="mk-free">Свободно: {free}</div></li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<div className="qr-backdrop" onClick={() => setModal(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name">{modal.label}</p>
|
||||
<img className="qr-img" src={modal.dataUrl} alt="QR" title="Нажми чтобы скопировать sen:// ссылку"
|
||||
onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />
|
||||
<p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button>
|
||||
<button className="btn btn--tonal" onClick={() => setModal(null)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
Copyright (c) 2026 Ivan Vasilev
|
||||
This source code is licensed under the MIT license found in the
|
||||
LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
/* Стили вкладки «Мастер-ключи»: дерево «мастер-ключ → устройства».
|
||||
Общая дизайн-система — в App.css. */
|
||||
|
||||
.mk-notice {
|
||||
font-size: 13px;
|
||||
color: var(--error-text);
|
||||
background: var(--error-bg);
|
||||
border-radius: 16px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.mk-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
|
||||
.mk-limit-input { width: 88px !important; }
|
||||
|
||||
/* ── таблица (десктоп) ── */
|
||||
.mk-table td, .mk-table th { white-space: nowrap; }
|
||||
.mk-table .td-actions { width: 150px; }
|
||||
.mk-table .mk-device-id { display: inline-block; max-width: 170px; vertical-align: middle; }
|
||||
.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; }
|
||||
.mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; }
|
||||
.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; }
|
||||
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
|
||||
.mk-tr-master:hover td { background: var(--surface-container); }
|
||||
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||
.mk-name .mk-caret { margin-left: -8px; }
|
||||
.mk-tr-master .mk-name { color: var(--on-surface); }
|
||||
.mk-tr-master .mk-name svg { color: var(--primary); }
|
||||
.mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */
|
||||
.mk-tr-tools:hover { background: transparent; }
|
||||
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
|
||||
.mk-flag { color: var(--neutral-text); font-size: 12px; }
|
||||
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
|
||||
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
|
||||
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
|
||||
}
|
||||
.mk-tr-device.mk-last td:first-child, .mk-tr-free.mk-last td:first-child { background-size: 2px 50%; }
|
||||
.mk-indent { position: relative; display: inline-block; padding-left: 36px; }
|
||||
.mk-indent::before {
|
||||
content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
|
||||
border-top: 2px solid var(--outline);
|
||||
}
|
||||
.mk-tr-free td { color: var(--neutral-dot); font-size: 12px; }
|
||||
.mk-tr-free:hover { background: transparent; }
|
||||
|
||||
/* ── дерево (мобильный) ── */
|
||||
.mk-tree, .mk-children { list-style: none; margin: 0; padding: 0; }
|
||||
.mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */
|
||||
|
||||
.mk-root {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 12px 18px 14px;
|
||||
}
|
||||
|
||||
/* родитель */
|
||||
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
|
||||
.mk-master-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mk-caret {
|
||||
width: 28px; height: 28px;
|
||||
border: 0; border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--neutral-text);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.mk-caret:hover { background: var(--state-neutral); }
|
||||
.mk-caret--folded { transform: rotate(-90deg); }
|
||||
|
||||
.mk-tools {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
/* два одинаковых блока: слева настройки ключа, справа серверы */
|
||||
.mk-box {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
.mk-box-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
|
||||
.mk-box .mk-box-title { margin-bottom: 2px; }
|
||||
/* строка настройки: слева название и пояснение, справа контрол */
|
||||
.mk-setting {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--outline-variant);
|
||||
}
|
||||
.mk-box-title + .mk-setting { border-top: 0; padding-top: 4px; }
|
||||
.mk-setting:last-child { padding-bottom: 0; }
|
||||
.mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
||||
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
|
||||
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
|
||||
|
||||
/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка».
|
||||
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
|
||||
.mk-meter {
|
||||
--mk-w: 136px;
|
||||
--meter-fill: var(--success-dot); /* < 2/3 — зелёный */
|
||||
--meter-on: #2a2118; /* цвет числа поверх заливки */
|
||||
position: relative;
|
||||
width: var(--mk-w); height: 20px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--outline-variant);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mk-meter-fill {
|
||||
position: absolute; left: 0; top: 0; bottom: 0;
|
||||
background: var(--meter-fill);
|
||||
overflow: hidden;
|
||||
transition: width 0.25s, background 0.25s;
|
||||
}
|
||||
/* числа по краям внутри полоски: текущее слева, лимит справа */
|
||||
.mk-meter-text {
|
||||
position: absolute; left: 0; top: 0;
|
||||
width: var(--mk-w); height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 11px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px; font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.mk-meter-text--on { color: var(--meter-on); }
|
||||
.mk-meter--warn { --meter-fill: var(--warn-dot); } /* от 2/3 — оранжевый */
|
||||
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
|
||||
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
|
||||
|
||||
/* серверы, отдаваемые по ключу (заглушка) */
|
||||
.mk-server {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--surface-container);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.mk-server input { accent-color: var(--primary); margin: 0; }
|
||||
.mk-server-name { font-weight: 500; }
|
||||
.mk-server-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-server-ip { font-family: var(--mono); margin-left: auto; }
|
||||
.mk-server-add {
|
||||
width: 100%; justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--outline);
|
||||
color: var(--neutral-dot);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mk-box .tip-wrap { width: 100%; }
|
||||
|
||||
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
||||
.mk-children { margin: 8px 0 0 18px; padding-left: 28px; border-left: 2px solid var(--outline); }
|
||||
.mk-node { position: relative; padding: 4px 0; }
|
||||
.mk-node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -28px; top: 50%;
|
||||
width: 28px;
|
||||
border-top: 2px solid var(--outline);
|
||||
}
|
||||
/* последний узел: линия родителя обрывается на его «ножке» */
|
||||
.mk-node:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -30px; top: calc(50% + 1px); bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--surface-bright);
|
||||
}
|
||||
|
||||
.mk-device {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
background: var(--surface-container);
|
||||
border-radius: 14px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.mk-device-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 160px; }
|
||||
.mk-device-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.mk-device-id {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--neutral-text);
|
||||
background: var(--surface-bright);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 1px 8px;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
||||
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-metric { display: inline-flex; gap: 4px; }
|
||||
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
|
||||
|
||||
.mk-free {
|
||||
border: 1px dashed var(--outline);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--neutral-dot);
|
||||
}
|
||||
|
||||
/* ── мобильный (design.md, часть IV): одна колонка, тач-цели ≥ 44px, rx/tx блоками
|
||||
50/50, действия рядом с большим пальцем ── */
|
||||
@media (max-width: 640px) {
|
||||
.mk-tree { display: flex; }
|
||||
.mk-limit-input { width: 100% !important; }
|
||||
|
||||
.mk-root { padding: 12px; }
|
||||
|
||||
/* родитель: шапка, затем ряд действий на всю ширину */
|
||||
.mk-master { flex-wrap: wrap; row-gap: 10px; }
|
||||
.mk-master .actions { width: 100%; gap: 8px; }
|
||||
.mk-caret { width: 44px; height: 44px; margin: -6px 0 -6px -8px; }
|
||||
.mk-meter { --mk-w: 112px; height: 24px; }
|
||||
|
||||
/* настройки узла: поля и кнопки на всю ширину */
|
||||
.mk-tools { grid-template-columns: minmax(0, 1fr); margin: 8px 0 4px; }
|
||||
.mk-setting { flex-direction: column; align-items: stretch; gap: 8px; }
|
||||
.mk-setting-ctl { width: 100%; }
|
||||
.mk-setting-ctl .btn { flex: 1; height: 44px; }
|
||||
.mk-setting-ctl .mk-limit-input { width: 100% !important; flex: 1; height: 44px; }
|
||||
.mk-tools .btn { width: 100%; height: 44px; justify-content: center; }
|
||||
|
||||
/* дерево уже: отступы поменьше, «ножки» короче */
|
||||
.mk-children { margin-left: 8px; padding-left: 14px; }
|
||||
.mk-node { padding: 4px 0; }
|
||||
.mk-node::before { left: -14px; width: 14px; }
|
||||
.mk-node:last-child::after { left: -16px; }
|
||||
|
||||
/* устройство — карточка: заголовок + чип, метрики 50/50, ряд действий */
|
||||
.mk-device { border-radius: 16px; padding: 12px; gap: 10px; }
|
||||
.mk-device-main { flex: 1 1 0; }
|
||||
.mk-device-title { flex-wrap: wrap; row-gap: 2px; }
|
||||
.mk-device-id { max-width: 100%; }
|
||||
.mk-device-traffic { width: 100%; gap: 8px; }
|
||||
.mk-metric {
|
||||
flex: 1; flex-direction: column; gap: 2px;
|
||||
background: var(--surface-bright);
|
||||
border-radius: 16px; padding: 8px 12px;
|
||||
}
|
||||
.mk-metric i { font-family: var(--font, inherit); font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-metric-name::after { content: attr(data-sfx); }
|
||||
.mk-metric b { font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
|
||||
/* кнопки действий: контурные, на всю ширину ряда, 44px */
|
||||
.mk-device .actions { width: 100%; gap: 8px; }
|
||||
.mk-master .btn-icon, .mk-device .btn-icon {
|
||||
flex: 1; width: auto; height: 44px;
|
||||
border: 1px solid var(--outline);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.mk-master .btn-icon--danger, .mk-device .btn-icon--danger { border-color: var(--error-dot); }
|
||||
|
||||
.mk-free { padding: 12px; font-size: 13px; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "masterkeys",
|
||||
"label": "Мастер-ключи",
|
||||
"icon": "key",
|
||||
"order": 1.5
|
||||
}
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
//
|
||||
// sen://-подписка: реестр мастер-ключей/устройств (ui.db), админ-роуты панели
|
||||
// (/ui/masterkeys, /ui/devices, за JWT) и публичный листенер /sub/v1/* на
|
||||
// отдельном порту SUB_PORT. Протокол и формат ссылки — docs/sen-link.md.
|
||||
//
|
||||
// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по
|
||||
// публичному ключу (/api/peers*) и общий профиль сервера (/api/profile).
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import path from "path";
|
||||
import express, { Request, Response, NextFunction, Router } from "express";
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
encodeSenLink, rawPublicKey, publicKeyFromRaw, spkiPin,
|
||||
signResponse, verifyRequest,
|
||||
} from "./sen";
|
||||
|
||||
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
|
||||
|
||||
interface MasterRow {
|
||||
id: number; label: string; secret: Buffer; device_limit: number;
|
||||
server_id: number; created_at: number; revoked_at: number | null;
|
||||
}
|
||||
interface DeviceRow {
|
||||
id: number; master_id: number; device_id: string; device_name: string; platform: string;
|
||||
pub_key: string; auth_pub: string; created_at: number; last_seen: number | null;
|
||||
rekey_requested: number; version: string;
|
||||
}
|
||||
|
||||
const TS_WINDOW = 300; // секунд, ±
|
||||
const DEFAULT_LIMIT = 3;
|
||||
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
|
||||
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
|
||||
|
||||
// pub_key WireGuard в пути к awg-ctrl — base64url (в обычном base64 есть «/»).
|
||||
const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url");
|
||||
|
||||
// ── Простой лимитер по IP ──────────────────────────────────────────────────
|
||||
function makeLimiter(max: number, windowMs: number) {
|
||||
const hits = new Map<string, { n: number; reset: number }>();
|
||||
return (ip: string): boolean => {
|
||||
const now = Date.now();
|
||||
const e = hits.get(ip);
|
||||
if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; }
|
||||
if (e.n >= max) return false;
|
||||
e.n++;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: string }) {
|
||||
const { uidb, ctrl } = deps;
|
||||
|
||||
// ── Настройка из окружения ─────────────────────────────────────────────
|
||||
const SUB_PORT = Number(process.env.SUB_PORT) || 0;
|
||||
const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on";
|
||||
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
|
||||
|
||||
let signKey: crypto.KeyObject | null = null;
|
||||
let signPub: Buffer | null = null;
|
||||
try {
|
||||
signKey = crypto.createPrivateKey(fs.readFileSync(file(process.env.SUB_SIGN_KEY_FILE, "sub_sign.key")));
|
||||
signPub = rawPublicKey(crypto.createPublicKey(signKey));
|
||||
} catch {
|
||||
console.warn("sub: ключ подписи не найден — sen://-подписка отключена");
|
||||
}
|
||||
|
||||
let tlsMaterial: { key: Buffer; cert: Buffer } | null = null;
|
||||
let tlsPin: Buffer | null = null;
|
||||
if (SUB_TLS) {
|
||||
try {
|
||||
tlsMaterial = {
|
||||
key: fs.readFileSync(file(process.env.SUB_TLS_KEY_FILE, "sub_tls.key")),
|
||||
cert: fs.readFileSync(file(process.env.SUB_TLS_CERT_FILE, "sub_tls.crt")),
|
||||
};
|
||||
tlsPin = spkiPin(tlsMaterial.cert.toString());
|
||||
} catch {
|
||||
console.warn("sub: SUB_TLS=on, но сертификат не найден — подписка отключена");
|
||||
signKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Схема ──────────────────────────────────────────────────────────────
|
||||
uidb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS master_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL,
|
||||
secret BLOB NOT NULL UNIQUE,
|
||||
device_limit INTEGER NOT NULL DEFAULT ${DEFAULT_LIMIT},
|
||||
server_id INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
pub_key TEXT NOT NULL UNIQUE,
|
||||
auth_pub TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER,
|
||||
rekey_requested INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (master_id, device_id)
|
||||
);
|
||||
`);
|
||||
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
|
||||
// таблицы колонки может не быть.
|
||||
if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version"))
|
||||
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
|
||||
uidb.pragma("foreign_keys = ON");
|
||||
|
||||
const q = {
|
||||
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE revoked_at IS NULL ORDER BY id"),
|
||||
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
|
||||
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
|
||||
masterInsert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO master_keys (label, secret, device_limit, created_at) VALUES (?, ?, ?, ?)"),
|
||||
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
|
||||
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
|
||||
masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE id = ?"),
|
||||
devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"),
|
||||
deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"),
|
||||
deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_id = ?"),
|
||||
deviceCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM devices WHERE master_id = ?"),
|
||||
deviceInsert: uidb.prepare<[number, string, string, string, string, string, number, string]>(
|
||||
"INSERT INTO devices (master_id, device_id, device_name, platform, pub_key, auth_pub, created_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"),
|
||||
deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"),
|
||||
deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? WHERE id = ?"),
|
||||
deviceRekeyed: uidb.prepare<[string, number]>("UPDATE devices SET pub_key = ?, rekey_requested = 0 WHERE id = ?"),
|
||||
deviceFlag: uidb.prepare<[number, number]>("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?"),
|
||||
};
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const hasCtrlOk = (s: number) => s >= 200 && s < 300;
|
||||
|
||||
// ── Общие куски ────────────────────────────────────────────────────────
|
||||
async function profile() {
|
||||
const r = await ctrl("GET", "/api/profile");
|
||||
if (!hasCtrlOk(r.status)) throw new Error("profile");
|
||||
return r.data as {
|
||||
name: string; endpoint: string; server_pub: string; gen: string;
|
||||
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":"));
|
||||
|
||||
async function buildConfig(dev: DeviceRow) {
|
||||
const [prof, peers] = await Promise.all([profile(), ctrl("GET", "/api/peers")]);
|
||||
if (!hasCtrlOk(peers.status)) throw new Error("peers");
|
||||
const peer = (peers.data.peers as { pub_key: string; ip: string; psk_key: string }[])
|
||||
.find(p => p.pub_key === dev.pub_key);
|
||||
if (!peer) throw new Error("peer missing");
|
||||
const cfg = {
|
||||
rekey: dev.rekey_requested === 1,
|
||||
endpoints: [`${hostOf(prof.endpoint)}:${SUB_PORT}`],
|
||||
servers: [{
|
||||
id: 0, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub,
|
||||
psk: peer.psk_key, address: `${peer.ip}/32`, dns: prof.dns,
|
||||
keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg,
|
||||
}],
|
||||
};
|
||||
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
|
||||
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
|
||||
return { rev, ...cfg };
|
||||
}
|
||||
|
||||
async function dropPeer(pub: string) {
|
||||
await ctrl("DELETE", `/api/peers/${pubParam(pub)}`); // 404 — уже нет, не страшно
|
||||
}
|
||||
|
||||
// ── Публичная часть: /sub/v1 ───────────────────────────────────────────
|
||||
const sub = express();
|
||||
sub.disable("x-powered-by");
|
||||
sub.use(express.json({
|
||||
limit: "4kb",
|
||||
verify: (req, _res, buf) => { (req as any).rawBody = buf; },
|
||||
}));
|
||||
|
||||
const registerLimit = makeLimiter(30, 15 * 60 * 1000);
|
||||
const failLimit = makeLimiter(30, 15 * 60 * 1000);
|
||||
const seenSigs = new Map<string, number>(); // подпись → когда забыть
|
||||
|
||||
function send(res: Response, status: number, payload: unknown) {
|
||||
res.status(status).json(signResponse(payload, signKey as crypto.KeyObject));
|
||||
}
|
||||
|
||||
// Проверка подписи запроса + защита от повторов. auth_pub — 32 байта.
|
||||
function checkSigned(req: Request, authPub: Buffer): boolean {
|
||||
const ts = Number(req.headers["x-sen-ts"]);
|
||||
const sig = String(req.headers["x-sen-sig"] ?? "");
|
||||
if (!Number.isInteger(ts) || Math.abs(ts - now()) > TS_WINDOW || !sig) return false;
|
||||
const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0);
|
||||
if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return false;
|
||||
const t = Date.now();
|
||||
for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k);
|
||||
if (seenSigs.has(sig)) return false;
|
||||
seenSigs.set(sig, t + 2 * TS_WINDOW * 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface DevReq extends Request { device?: DeviceRow }
|
||||
|
||||
function deviceAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const id = Number(req.headers["x-sen-device"]);
|
||||
const dev = Number.isInteger(id) ? q.deviceById.get(id) as DeviceRow | undefined : undefined;
|
||||
if (dev && checkSigned(req, Buffer.from(dev.auth_pub, "base64url"))) {
|
||||
(req as DevReq).device = dev;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает.
|
||||
if (!failLimit(req.socket.remoteAddress ?? "unknown")) { send(res, 429, { error: "rate_limited" }); return; }
|
||||
send(res, 401, { error: "unauthorized" });
|
||||
}
|
||||
|
||||
const wrap = (fn: (req: Request, res: Response) => Promise<void>) =>
|
||||
(req: Request, res: Response) => { fn(req, res).catch(() => { send(res, 502, { error: "unavailable" }); }); };
|
||||
|
||||
sub.post("/sub/v1/register", wrap(async (req, res) => {
|
||||
const ip = req.socket.remoteAddress ?? "unknown";
|
||||
if (!registerLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; }
|
||||
|
||||
const b = (req.body ?? {}) as Record<string, unknown>;
|
||||
const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null;
|
||||
const secretB = str(b.sub, /^[A-Za-z0-9_-]{22}$/);
|
||||
const deviceId = str(b.device_id, /^[\w.-]{1,64}$/);
|
||||
const pubKey = str(b.pub_key, B64_PUB);
|
||||
const authPubS = str(b.auth_pub, /^[A-Za-z0-9_-]{43}$/);
|
||||
const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : "";
|
||||
const platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : "";
|
||||
const version = str(b.version, CLIENT_VERSION) ?? "";
|
||||
if (!secretB || !deviceId || !pubKey || !authPubS) { send(res, 400, { error: "bad_request" }); return; }
|
||||
|
||||
// Регистрация подписана ключом auth_pub из самого тела — доказательство владения.
|
||||
const authPub = Buffer.from(authPubS, "base64url");
|
||||
if (authPub.length !== 32 || !checkSigned(req, authPub)) { send(res, 401, { error: "unauthorized" }); return; }
|
||||
|
||||
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
|
||||
if (!master) { send(res, 404, { error: "not_found" }); return; }
|
||||
|
||||
let deviceRowId = 0;
|
||||
let oldPub: string | null = null;
|
||||
try {
|
||||
uidb.transaction(() => {
|
||||
const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined;
|
||||
if (existing) { oldPub = existing.pub_key; q.deviceDelete.run(existing.id); }
|
||||
else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit)
|
||||
throw Object.assign(new Error("limit"), { code: "limit" });
|
||||
deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid);
|
||||
})();
|
||||
} catch (e: any) {
|
||||
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; }
|
||||
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
|
||||
}
|
||||
|
||||
if (oldPub) await dropPeer(oldPub);
|
||||
const r = await ctrl("POST", "/api/peers", { pub_key: pubKey, owner: `m${master.id}/d${deviceRowId}` });
|
||||
if (!hasCtrlOk(r.status)) {
|
||||
q.deviceDelete.run(deviceRowId);
|
||||
send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" });
|
||||
return;
|
||||
}
|
||||
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
|
||||
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
|
||||
// без отдельного запроса списка.
|
||||
send(res, 201, {
|
||||
device: dev.id, config: await buildConfig(dev),
|
||||
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
|
||||
});
|
||||
}));
|
||||
|
||||
// Сколько мест у ключа занято — клиент показывает это, пока ссылку только вставили и ещё ничего не
|
||||
// регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register,
|
||||
// а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать.
|
||||
sub.post("/sub/v1/peek", wrap(async (req, res) => {
|
||||
const ip = req.socket.remoteAddress ?? "unknown";
|
||||
const secretB = typeof (req.body as any)?.sub === "string" && /^[A-Za-z0-9_-]{22}$/.test((req.body as any).sub) ? (req.body as any).sub as string : null;
|
||||
const master = secretB ? q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined : undefined;
|
||||
if (!master) {
|
||||
if (!failLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; }
|
||||
send(res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
|
||||
return;
|
||||
}
|
||||
send(res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
|
||||
}));
|
||||
|
||||
sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
q.deviceSeen.run(now(), dev.id);
|
||||
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
|
||||
const v = String(req.headers["x-sen-version"] ?? "");
|
||||
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
|
||||
send(res, 200, { config: await buildConfig(dev) });
|
||||
}));
|
||||
|
||||
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
|
||||
// можно лишь себя (DELETE /sub/v1/device); чужие устройства убирает панель.
|
||||
sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
const master = q.masterById.get(dev.master_id) as MasterRow | undefined;
|
||||
if (!master) { send(res, 404, { error: "not_found" }); return; }
|
||||
send(res, 200, {
|
||||
name: master.label,
|
||||
device_limit: master.device_limit,
|
||||
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
|
||||
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
|
||||
created_at: d.created_at, last_seen: d.last_seen, current: d.id === dev.id,
|
||||
})),
|
||||
});
|
||||
}));
|
||||
|
||||
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key;
|
||||
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(res, 400, { error: "bad_request" }); return; }
|
||||
const r = await ctrl("PUT", `/api/peers/${pubParam(dev.pub_key)}`, { pub_key: pub });
|
||||
if (!hasCtrlOk(r.status)) { send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); return; }
|
||||
q.deviceRekeyed.run(pub, dev.id);
|
||||
send(res, 200, { config: await buildConfig(q.deviceById.get(dev.id) as DeviceRow) });
|
||||
}));
|
||||
|
||||
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
await dropPeer(dev.pub_key);
|
||||
q.deviceDelete.run(dev.id);
|
||||
send(res, 200, { ok: true });
|
||||
}));
|
||||
|
||||
sub.use((_req, res) => { send(res, 404, { error: "not_found" }); });
|
||||
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
|
||||
sub.use((_err: unknown, _req: Request, res: Response, _next: NextFunction) => { send(res, 400, { error: "bad_request" }); });
|
||||
|
||||
// ── Админ-роуты панели (монтируются в server.ts за JWT) ─────────────────
|
||||
const aw = (fn: (req: Request, res: Response) => Promise<void>) =>
|
||||
(req: Request, res: Response) => { fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); }); };
|
||||
const idOf = (req: Request) => Number(req.params.id);
|
||||
const LABEL = /^[^\r\n<>]{1,40}$/;
|
||||
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
|
||||
|
||||
const masterKeys = Router();
|
||||
|
||||
masterKeys.get("/", (_req, res) => {
|
||||
res.json({
|
||||
enabled: signKey !== null,
|
||||
tls: tlsPin !== null,
|
||||
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
|
||||
id: m.id, label: m.label, device_limit: m.device_limit,
|
||||
devices: m.devices, server_id: m.server_id, created_at: m.created_at,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
masterKeys.post("/", (req, res) => {
|
||||
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
|
||||
if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; }
|
||||
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
|
||||
if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
|
||||
const info = q.masterInsert.run(label, crypto.randomBytes(16), limit, now());
|
||||
res.status(201).json({ id: Number(info.lastInsertRowid), label, device_limit: limit, devices: 0 });
|
||||
});
|
||||
|
||||
masterKeys.patch("/:id", (req, res) => {
|
||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
|
||||
const nl = label ?? m.label, nd = device_limit ?? m.device_limit;
|
||||
if (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; }
|
||||
if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
|
||||
q.masterUpdate.run(nl, nd, m.id);
|
||||
res.json({ id: m.id, label: nl, device_limit: nd });
|
||||
});
|
||||
|
||||
masterKeys.post("/:id/rotate", (req, res) => {
|
||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
q.masterSecret.run(crypto.randomBytes(16), m.id); // устройства продолжают работать: у них auth_pub
|
||||
res.json({ id: m.id });
|
||||
});
|
||||
|
||||
masterKeys.post("/:id/rekey", (req, res) => {
|
||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
q.deviceFlag.run(-1, m.id);
|
||||
res.json({ id: m.id });
|
||||
});
|
||||
|
||||
masterKeys.delete("/:id", aw(async (req, res) => {
|
||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) await dropPeer(d.pub_key);
|
||||
q.masterDelete.run(m.id); // devices уйдут каскадом
|
||||
res.json({ success: true, id: m.id });
|
||||
}));
|
||||
|
||||
masterKeys.get("/:id/link", aw(async (req, res) => {
|
||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
if (!signKey || !signPub || !SUB_PORT) { res.status(503).json({ error: "Подписка не настроена на сервере" }); return; }
|
||||
const prof = await profile();
|
||||
const link = encodeSenLink({
|
||||
tls: tlsPin !== null,
|
||||
addrs: [{ host: hostOf(prof.endpoint), port: SUB_PORT }],
|
||||
secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label,
|
||||
});
|
||||
res.json({ link, tls: tlsPin !== null });
|
||||
}));
|
||||
|
||||
masterKeys.get("/:id/devices", aw(async (req, res) => {
|
||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
|
||||
const r = await ctrl("GET", "/api/peers");
|
||||
if (hasCtrlOk(r.status)) for (const p of r.data.peers) stats.set(p.pub_key, p);
|
||||
res.json({
|
||||
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => ({
|
||||
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
|
||||
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
|
||||
...pick(stats.get(d.pub_key)),
|
||||
})),
|
||||
});
|
||||
}));
|
||||
|
||||
function pick(p?: { online: boolean; lastHandshake: number; rx: number; tx: number }) {
|
||||
return { online: p?.online ?? false, lastHandshake: p?.lastHandshake ?? 0, rx: p?.rx ?? 0, tx: p?.tx ?? 0 };
|
||||
}
|
||||
|
||||
const devices = Router();
|
||||
|
||||
devices.delete("/:id", aw(async (req, res) => {
|
||||
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
|
||||
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
|
||||
await dropPeer(d.pub_key);
|
||||
q.deviceDelete.run(d.id);
|
||||
res.json({ success: true, id: d.id });
|
||||
}));
|
||||
|
||||
devices.post("/:id/rekey", (req, res) => {
|
||||
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
|
||||
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
|
||||
q.deviceFlag.run(d.id, -1);
|
||||
res.json({ id: d.id });
|
||||
});
|
||||
|
||||
devices.post("/:id/psk", aw(async (req, res) => {
|
||||
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
|
||||
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
|
||||
const r = await ctrl("POST", `/api/peers/${pubParam(d.pub_key)}/psk`);
|
||||
res.status(hasCtrlOk(r.status) ? 200 : 502).json(hasCtrlOk(r.status) ? { id: d.id } : { error: "awg-ctrl отказал" });
|
||||
}));
|
||||
|
||||
// ── Листенер ───────────────────────────────────────────────────────────
|
||||
function start() {
|
||||
if (!signKey) return;
|
||||
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
|
||||
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
|
||||
server.listen(SUB_PORT, () => {
|
||||
console.log(`sub: listening on :${SUB_PORT} (${tlsMaterial ? "https" : "http"})`);
|
||||
});
|
||||
}
|
||||
|
||||
return { masterKeys, devices, start };
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
@@ -93,6 +93,13 @@ const SERVICES = {
|
||||
// ui.db (ключи внешнего API) в каталоге данных AWG (вне PROJECT) —
|
||||
// переживает переустановку.
|
||||
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
|
||||
// Подписка sen:// (мастер-ключи SenAWG): порт, режим TLS, ключ подписи и
|
||||
// сертификат. Без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
|
||||
SUB_PORT: process.env.SUB_PORT ?? "",
|
||||
SUB_TLS: process.env.SUB_TLS ?? "off",
|
||||
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
|
||||
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
|
||||
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
|
||||
},
|
||||
pidFile: "/tmp/awg-ui.pid",
|
||||
logFile: path.join(LOGS, "awg-ui.log"),
|
||||
|
||||
+193
-28
@@ -22,7 +22,7 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
|
||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||
# При бампе версии правь оба места.
|
||||
VERSION="0.2.1"
|
||||
VERSION="0.3.0"
|
||||
CHANNEL="${CHANNEL:-Beta}"
|
||||
|
||||
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
|
||||
@@ -52,6 +52,12 @@ UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PR
|
||||
# Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl.
|
||||
INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key"
|
||||
INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key"
|
||||
# Подписка sen:// (SenAWG): ключ подписи ответов (Ed25519) и, при SUB_TLS=on, самоподписанный
|
||||
# сертификат. В отличие от internal_auth они НЕ эфемерны — публичный ключ подписи и отпечаток
|
||||
# сертификата зашиты во все выданные sen:// ссылки.
|
||||
SUB_SIGN_PRIV="$AWG_DIR/sub_sign.key"
|
||||
SUB_TLS_KEY="$AWG_DIR/sub_tls.key"
|
||||
SUB_TLS_CRT="$AWG_DIR/sub_tls.crt"
|
||||
IFACE="awg1"
|
||||
AWG_PORT="47619"
|
||||
SUBNET="10.9"
|
||||
@@ -72,13 +78,51 @@ 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)"
|
||||
|
||||
# Считаем все три пункта, НЕ падая на первом, — чтобы показать полный чеклист
|
||||
# Семейство дистрибутива решает, как ставить AmneziaWG:
|
||||
# ubuntu — add-apt-repository ppa:amnezia/ppa, заголовки linux-headers-generic;
|
||||
# debian — ни software-properties-common (в Debian 13 его нет), ни
|
||||
# linux-headers-generic там не существует: PPA подключаем вручную
|
||||
# (ключ + signed-by), заголовки — только под текущее ядро.
|
||||
# Производные (Mint, Pop!_OS, Kali…) определяются через ID_LIKE.
|
||||
OS_ID=""; OS_LIKE=""; OS_CODENAME=""; OS_NAME=""
|
||||
if [[ -r /etc/os-release ]]; then
|
||||
OS_ID=$(. /etc/os-release; echo "${ID:-}")
|
||||
OS_LIKE=$(. /etc/os-release; echo "${ID_LIKE:-}")
|
||||
OS_CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME:-}")
|
||||
OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-}")
|
||||
fi
|
||||
if [[ "$OS_ID" == "ubuntu" || " $OS_LIKE " == *" ubuntu "* ]]; then
|
||||
OS_FAMILY="ubuntu"
|
||||
elif [[ "$OS_ID" == "debian" || " $OS_LIKE " == *" debian "* ]]; then
|
||||
OS_FAMILY="debian"
|
||||
else
|
||||
OS_FAMILY=""
|
||||
fi
|
||||
# Вариант ядра без версии: 6.12.85+deb13-cloud-amd64 → cloud-amd64,
|
||||
# 6.1.0-18-cloud-amd64 → cloud-amd64. Нужен для мета-пакетов Debian
|
||||
# (linux-image-<вариант> / linux-headers-<вариант>).
|
||||
KFLAVOUR=$(sed -E 's/^[0-9][^-]*-([0-9]+-)?//' <<< "$KERNEL")
|
||||
# Что советовать, если заголовков под текущее ядро нет.
|
||||
if [[ "$OS_FAMILY" == "debian" ]]; then
|
||||
HDR_FIX="apt-get update && apt-get install -y linux-image-$KFLAVOUR linux-headers-$KFLAVOUR && reboot"
|
||||
else
|
||||
HDR_FIX="apt-get install -y linux-generic && reboot"
|
||||
fi
|
||||
|
||||
echo " Ядро: $KERNEL · виртуализация: $VIRT · ${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)}"
|
||||
|
||||
# Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист
|
||||
# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим
|
||||
# с кодом 0 (чистый выход, без вида «упало с ошибкой»).
|
||||
VIRT_OK=1; HDR_OK=1; NET_OK=1
|
||||
VIRT_WHY=""; HDR_WHY=""; NET_WHY=""
|
||||
OS_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1
|
||||
OS_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY=""
|
||||
|
||||
# 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian.
|
||||
if [[ -z "$OS_FAMILY" ]]; then
|
||||
OS_OK=0
|
||||
OS_WHY="дистрибутив '${OS_NAME:-${OS_ID:-неизвестно}}' не поддерживается — нужен Ubuntu или Debian"
|
||||
fi
|
||||
|
||||
# 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом.
|
||||
case "$VIRT" in
|
||||
@@ -98,7 +142,7 @@ 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-ядре"
|
||||
HDR_WHY="нет заголовков под ядро $KERNEL в apt. Решение: $HDR_FIX, затем запусти install.sh заново"
|
||||
fi
|
||||
|
||||
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
|
||||
@@ -117,13 +161,15 @@ done
|
||||
|
||||
# Чеклист: ✓ — пройдено, ✗ — нет.
|
||||
mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; }
|
||||
mark "$OS_OK" "Дистрибутив"
|
||||
mark "$VIRT_OK" "Виртуализация"
|
||||
mark "$HDR_OK" "Заголовки ядра"
|
||||
mark "$NET_OK" "Доступ в интернет"
|
||||
|
||||
if [[ "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then
|
||||
if [[ "$OS_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then
|
||||
echo
|
||||
warn "Установка невозможна — не пройдены проверки:"
|
||||
[[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY"
|
||||
[[ "$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"
|
||||
@@ -427,6 +473,28 @@ if [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# sen://-подписка (мастер-ключи для SenAWG). Порт и режим TLS зашиты в уже выданные ссылки,
|
||||
# поэтому при KEEP_DATA=y берём прежние значения из существующего cli.env.
|
||||
PREV_ENV="$PROJECT/cli/cli.env"
|
||||
prev_val() { grep -s "^$1=" "$PREV_ENV" | tail -1 | cut -d= -f2- || true; }
|
||||
SUB_PORT="${SUB_PORT:-}"
|
||||
SUB_TLS="${SUB_TLS:-}"
|
||||
if [[ "$KEEP_DATA" == "y" ]]; then
|
||||
[[ -z "$SUB_PORT" ]] && SUB_PORT="$(prev_val SUB_PORT)"
|
||||
[[ -z "$SUB_TLS" ]] && SUB_TLS="$(prev_val SUB_TLS)"
|
||||
fi
|
||||
if [[ -z "$SUB_PORT" ]]; then
|
||||
read -rp " Sub port для sen:// (Enter — случайный): " SUB_PORT
|
||||
while [[ -z "$SUB_PORT" || "$SUB_PORT" == "$UI_PORT" || "$SUB_PORT" == "$AWGCTRL_PORT" ]]; do
|
||||
SUB_PORT=$(( (RANDOM % 22768) + 32768 ))
|
||||
done
|
||||
fi
|
||||
if [[ -z "$SUB_TLS" ]]; then
|
||||
read -rp " Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " ST
|
||||
[[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on"
|
||||
fi
|
||||
[[ "$SUB_TLS" == "on" ]] || SUB_TLS="off"
|
||||
|
||||
# Имя сервера: если база уже существует (DB_EXISTS, проверено заранее в
|
||||
# preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем.
|
||||
if [[ "$DB_EXISTS" == "y" ]]; then
|
||||
@@ -443,6 +511,7 @@ echo " Server name: $SERVER_NAME"
|
||||
echo " AWG port: $AWG_PORT (udp)"
|
||||
echo " awgctrl port: $AWGCTRL_PORT"
|
||||
echo " UI port: $UI_PORT"
|
||||
echo " Sub port: $SUB_PORT (tcp, sen://, TLS: $SUB_TLS)"
|
||||
echo " UI логин: $UI_USER"
|
||||
echo " Net interface: $NET_IFACE"
|
||||
if [[ "$KEEP_DATA" == "y" ]]; then
|
||||
@@ -461,30 +530,89 @@ start_logging
|
||||
# секции «Проверка совместимости»; $KERNEL задан там же.
|
||||
step "1/7 AmneziaWG"
|
||||
|
||||
# Debian: add-apt-repository нет (software-properties-common из Debian 13 убран),
|
||||
# а PPA под Debian-релизы не собирается — подключаем его вручную на Ubuntu-серию
|
||||
# с glibc не новее дебиановской. Модуль приезжает DKMS-исходниками и от серии
|
||||
# не зависит; серия важна только для бинарника amneziawg-tools.
|
||||
# AMNEZIA_PPA_SUITE — задать серию вручную (focal/jammy/noble).
|
||||
AMNEZIA_PPA_URL="https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu"
|
||||
AMNEZIA_PPA_FPR="75C9DD72C799870E310542E24166F2C257290828" # Launchpad PPA for Iurii Egorov
|
||||
AMNEZIA_KEYRING="/etc/apt/keyrings/amnezia-ppa.gpg"
|
||||
add_amnezia_ppa_debian() {
|
||||
local suite="${AMNEZIA_PPA_SUITE:-}"
|
||||
if [[ -z "$suite" ]]; then
|
||||
case "$OS_CODENAME" in
|
||||
bullseye) suite="focal" ;;
|
||||
bookworm) suite="jammy" ;;
|
||||
trixie) suite="noble" ;;
|
||||
*) suite="noble"
|
||||
warn "Debian '${OS_CODENAME:-неизвестно}' не сопоставлен с серией PPA — берём noble (задать вручную: AMNEZIA_PPA_SUITE=…)" ;;
|
||||
esac
|
||||
fi
|
||||
echo " → подключение PPA amnezia (Debian ${OS_CODENAME:-?} → серия $suite)"
|
||||
|
||||
# Ключ берём по полному отпечатку и сверяем отпечаток после загрузки —
|
||||
# apt-key в Debian 12+ уже нет, а доверять ответу keyserver вслепую нельзя.
|
||||
local tmp gh fprs=""
|
||||
tmp=$(mktemp); gh=$(mktemp -d)
|
||||
if curl -fsSL --connect-timeout 15 \
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x${AMNEZIA_PPA_FPR}" \
|
||||
| gpg --dearmor > "$tmp" 2>/dev/null; then
|
||||
fprs=$(GNUPGHOME="$gh" gpg --batch --show-keys --with-colons "$tmp" 2>/dev/null || true)
|
||||
fi
|
||||
if ! grep -q "^fpr:::::::::${AMNEZIA_PPA_FPR}:" <<< "$fprs"; then
|
||||
rm -rf "$tmp" "$gh"
|
||||
fail "Не удалось получить ключ PPA amnezia ($AMNEZIA_PPA_FPR) с keyserver.ubuntu.com"
|
||||
fi
|
||||
install -d -m 0755 /etc/apt/keyrings
|
||||
install -m 0644 "$tmp" "$AMNEZIA_KEYRING"
|
||||
rm -rf "$tmp" "$gh"
|
||||
|
||||
echo "deb [signed-by=$AMNEZIA_KEYRING] $AMNEZIA_PPA_URL $suite main" \
|
||||
> /etc/apt/sources.list.d/amnezia-ppa.list
|
||||
ok "PPA amnezia подключён: $suite, ключ $AMNEZIA_KEYRING"
|
||||
}
|
||||
|
||||
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 " → установка зависимостей сборки + заголовков ядра"
|
||||
# iptables — в обоих списках: его зовёт PostUp в awg1.conf, а в Debian 13
|
||||
# (nftables по умолчанию) он в минимальной системе не установлен.
|
||||
if [[ "$OS_FAMILY" == "debian" ]]; then
|
||||
DEPS=(gnupg ca-certificates curl dkms build-essential iptables)
|
||||
else
|
||||
DEPS=(software-properties-common python3-launchpadlib gnupg2 dkms build-essential iptables)
|
||||
fi
|
||||
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 "Не удалось установить зависимости или заголовки ядра"
|
||||
apt-get install -y "${DEPS[@]}" \
|
||||
|| fail "Не удалось установить зависимости сборки: ${DEPS[*]}"
|
||||
|
||||
# Заголовки — отдельным шагом: их нехватка — самая частая причина сбоя, и на
|
||||
# неё нужен точный совет, а не общее «не удалось установить». Ошибку apt
|
||||
# здесь не валим: решает наличие /lib/modules/$KERNEL/build ниже.
|
||||
HDR_PKGS=("linux-headers-$KERNEL")
|
||||
[[ "$OS_FAMILY" == "ubuntu" ]] && HDR_PKGS+=(linux-headers-generic)
|
||||
echo " → установка заголовков ядра: ${HDR_PKGS[*]}"
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
|
||||
apt-get install -y "${HDR_PKGS[@]}" \
|
||||
|| warn "apt не смог поставить ${HDR_PKGS[*]}"
|
||||
|
||||
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 заново."
|
||||
if [[ "$OS_FAMILY" == "debian" ]]; then
|
||||
# Debian держит в архиве только последнюю сборку ядра: если сервер
|
||||
# давно не перезагружали, пакета под запущенное ядро там уже нет.
|
||||
warn "Debian хранит в архиве только свежую сборку ядра — запущенное $KERNEL"
|
||||
warn "уже устарело. Поставь актуальное ядро вместе с заголовками:"
|
||||
else
|
||||
warn "Часто бывает на кастомном ядре провайдера. Решение:"
|
||||
fi
|
||||
warn " $HDR_FIX"
|
||||
warn "и после перезагрузки запусти install.sh заново."
|
||||
fail "Отсутствуют заголовки ядра $KERNEL — DKMS не соберёт модуль"
|
||||
fi
|
||||
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
|
||||
@@ -496,6 +624,8 @@ else
|
||||
# apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту.
|
||||
if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
|
||||
ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен"
|
||||
elif [[ "$OS_FAMILY" == "debian" ]]; then
|
||||
add_amnezia_ppa_debian
|
||||
else
|
||||
echo " → add-apt-repository ppa:amnezia/ppa"
|
||||
add-apt-repository -y ppa:amnezia/ppa \
|
||||
@@ -688,12 +818,38 @@ fi
|
||||
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 включён"
|
||||
# Подписка sen://. ⚠️ Существующие файлы при KEEP_DATA=y сохраняем: публичный ключ подписи и
|
||||
# отпечаток сертификата зашиты во все выданные ссылки, новый ключ сломал бы каждую. При чистой
|
||||
# установке ui.db уходит в бэкап (старые мастер-ключи всё равно недействительны) — ключи новые.
|
||||
if [[ "$KEEP_DATA" == "y" && -f "$SUB_SIGN_PRIV" ]]; then
|
||||
ok "Ключ подписи sen:// сохранён (существующие ссылки продолжат работать)"
|
||||
else
|
||||
( umask 077; openssl genpkey -algorithm ed25519 -out "$SUB_SIGN_PRIV" )
|
||||
chmod 600 "$SUB_SIGN_PRIV"
|
||||
ok "Ключ подписи sen:// создан"
|
||||
fi
|
||||
if [[ "$SUB_TLS" == "on" ]]; then
|
||||
if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then
|
||||
ok "Сертификат подписки sen:// сохранён"
|
||||
else
|
||||
( umask 077
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
|
||||
-keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=sen" 2>/dev/null )
|
||||
chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT"
|
||||
ok "Самоподписанный сертификат подписки sen:// создан (10 лет)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Свой файл в /etc/sysctl.d, а не /etc/sysctl.conf: в Debian 13 systemd-sysctl
|
||||
# /etc/sysctl.conf при загрузке больше не читает — forwarding включился бы
|
||||
# только до первой перезагрузки, и клиенты остались бы без интернета.
|
||||
SYSCTL_FILE="/etc/sysctl.d/99-awg-control.conf"
|
||||
cat > "$SYSCTL_FILE" <<'SYSCTL'
|
||||
net.ipv4.ip_forward=1
|
||||
net.ipv6.conf.all.forwarding=1
|
||||
SYSCTL
|
||||
sysctl -q -p "$SYSCTL_FILE"
|
||||
ok "IP forwarding включён ($SYSCTL_FILE)"
|
||||
|
||||
# Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента
|
||||
# AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились.
|
||||
@@ -848,8 +1004,9 @@ if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: act
|
||||
|| warn "ufw route allow не сработал — трафик клиентов может резаться"
|
||||
ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp"
|
||||
ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp"
|
||||
ufw allow "${SUB_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${SUB_PORT}/tcp"
|
||||
ufw reload >/dev/null 2>&1 || true
|
||||
ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp"
|
||||
ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp, ${UI_PORT}/tcp и ${SUB_PORT}/tcp"
|
||||
else
|
||||
echo " → UFW не активен, правила фаервола не трогаем"
|
||||
fi
|
||||
@@ -888,6 +1045,13 @@ UI_PORT=${UI_PORT}
|
||||
UI_USER=${UI_USER}
|
||||
UI_PASS=${UI_PASS}
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
|
||||
# ── подписка sen:// для SenAWG (мастер-ключи) ──────────────────────────────
|
||||
SUB_PORT=${SUB_PORT}
|
||||
SUB_TLS=${SUB_TLS}
|
||||
SUB_SIGN_KEY_FILE=${SUB_SIGN_PRIV}
|
||||
SUB_TLS_KEY_FILE=${SUB_TLS_KEY}
|
||||
SUB_TLS_CERT_FILE=${SUB_TLS_CRT}
|
||||
ENV
|
||||
|
||||
# Имя продукта попадает в cli.env, только если оператор задал его явно
|
||||
@@ -906,6 +1070,7 @@ echo
|
||||
echo -e " ${BLD}Сервисы:${NC}"
|
||||
echo " awg-ctrl → http://localhost:${AWGCTRL_PORT} (внутренний)"
|
||||
echo " awg-ui → http://${SERVER_IP}:${UI_PORT}"
|
||||
echo " sen:// → $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} (подписка SenAWG)"
|
||||
echo
|
||||
echo -e " ${BLD}${YLW}UI логин:${NC} ${UI_USER}"
|
||||
echo -e " ${BLD}${YLW}UI пароль:${NC} ${UI_PASS}"
|
||||
|
||||
Reference in New Issue
Block a user