Compare commits
6
Commits
v0.3.1
...
eb0ec2878c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb0ec2878c | ||
|
|
a85f5d87b2 | ||
|
|
c5e1dc9b32 | ||
|
|
3eede2f015 | ||
|
|
2054122110 | ||
|
|
129f29d6be |
@@ -4,6 +4,7 @@
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
cli/cli.env
|
||||
awg-ui/panel.id
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -83,6 +83,39 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
|
||||
`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить»
|
||||
ключ = удалить и создать заново.
|
||||
|
||||
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка;
|
||||
`0` — сервер самой панели, остальные — подключённые ноды). Пользователи `vpn://`
|
||||
заводятся на сервере ключа по умолчанию; другой сервер из набора выбирается
|
||||
заголовком `X-Server-Id: <id>`, сервер вне набора → `404`.
|
||||
|
||||
#### Мастер-ключи `sen://`
|
||||
|
||||
Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое
|
||||
устройство само регистрируется по ссылке и получает пира на всех серверах ключа,
|
||||
его приватный ключ сервер не видит. API-ключ видит и меняет мастер-ключ, только
|
||||
если **все** его серверы входят в набор API-ключа; остальные для него не
|
||||
существуют (`404`).
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
|
||||
-H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \
|
||||
-d '{"label":"Семья","device_limit":3}' | jq -r .link
|
||||
```
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) |
|
||||
| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, label, device_limit, devices, servers, created_at, deleting? }] }` |
|
||||
| `GET /api/v1/masterkeys/:id` | Ключ, ссылка и устройства одним запросом: те же поля + `link`, `tls`, `device_list` (`devices` — счётчик) |
|
||||
| `PATCH /api/v1/masterkeys/:id` | `{ label?, device_limit?, servers? }` — смена серверов сразу добавляет/снимает пиров устройств |
|
||||
| `POST /api/v1/masterkeys/:id/rotate` | Новая ссылка `{ id, link, tls }`; старая не принимает новые устройства, подключённые работают дальше |
|
||||
| `POST /api/v1/masterkeys/:id/rekey` | Все устройства сменят ключи WireGuard при следующем опросе (IP и PSK сохраняются) |
|
||||
| `DELETE /api/v1/masterkeys/:id` | `{ success, id, pending }` — ключ и устройства отключаются сразу; `pending` — серверы (ноды не в сети), которые ещё не сняли пиров, до тех пор ключ в списке с `deleting` |
|
||||
| `GET /api/v1/masterkeys/:id/devices` | `{ devices: [{ id, device_name, platform, version, online, lastHandshake, rx, tx, last_seen, servers_ok, servers_total, … }] }` — статистика по всем серверам ключа |
|
||||
| `DELETE /api/v1/masterkeys/:id/devices/:device` | Отвязывает устройство, место освобождается |
|
||||
| `POST /api/v1/masterkeys/:id/devices/:device/rekey` | Устройство сменит ключ при следующем опросе |
|
||||
| `POST /api/v1/masterkeys/:id/devices/:device/psk` | Новый PSK: `{ id, pending }` — `pending` серверов ждут ноду |
|
||||
|
||||
| Код | Когда |
|
||||
|---|---|
|
||||
| `401 {"error":"API key required"}` | заголовка нет или он не начинается с `awgk_` |
|
||||
@@ -92,8 +125,9 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
|
||||
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне
|
||||
каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про
|
||||
внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией.
|
||||
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` и
|
||||
`DELETE /ui/apikeys/:id`.
|
||||
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем
|
||||
`servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
|
||||
перевыпуская ключ, и `DELETE /ui/apikeys/:id`. Подробно — `awg-ui/API.md`.
|
||||
|
||||
### Авторизация панели
|
||||
|
||||
@@ -115,7 +149,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
||||
|
||||
| Метод | Ответ |
|
||||
|---|---|
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.3" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||
|
||||
@@ -156,6 +190,22 @@ base64url-декодировать, отбросить первые 4 байта
|
||||
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
|
||||
поднимается обратно на 2.0.
|
||||
|
||||
### Мастер-ключи
|
||||
|
||||
Те же операции, что в `/api/v1/masterkeys`, но без ограничения набором серверов.
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, label, device_limit, devices, servers, created_at, deleting? }] }` |
|
||||
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` |
|
||||
| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
|
||||
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
|
||||
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
|
||||
| `POST /ui/masterkeys/:id/rotate`, `/rekey` | Новая ссылка / смена ключей у всех устройств |
|
||||
| `DELETE /ui/masterkeys/:id` | `{ success, id, pending }` |
|
||||
| `DELETE /ui/devices/:id` | Отвязать устройство |
|
||||
| `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства |
|
||||
|
||||
## Управление
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-agent",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||
"scripts": {
|
||||
|
||||
+54
-1
@@ -305,6 +305,7 @@ const stmts = {
|
||||
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 = ?"),
|
||||
peerOwner: db.prepare<[string, string]>("UPDATE peers SET owner = ? 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"),
|
||||
@@ -946,8 +947,52 @@ function removePeer(pub: string) {
|
||||
logger.info("peer removed", { owner: peer.owner });
|
||||
}
|
||||
|
||||
// Сведение пиров одной панели: панель присылает полный список ключей, которые ей нужны
|
||||
// на этом сервере, — всё остальное из её области («<panel>/…») снимается. Удаляем только
|
||||
// по такому явному списку, никогда по времени или неактивности: панель не на связи —
|
||||
// ничего не происходит. Пиры без области (owner «m1/d2», выданы до областей) панель
|
||||
// забирает себе, только если их ключ есть в её списке; остальные не трогаем — чьи они,
|
||||
// уже не разобрать.
|
||||
const PANEL_ID = /^[a-z0-9]{8,16}$/;
|
||||
const LEGACY_OWNER = /^m\d+\/d\d+$/;
|
||||
const SYNC_MAX = 10_000;
|
||||
|
||||
function syncOwnedPeers(panel: string, keep: string[]): {
|
||||
present: string[]; removed: number; legacy: { pub_key: string; owner: string; ip: string }[];
|
||||
} {
|
||||
const want = new Set(keep);
|
||||
const prefix = panel + "/";
|
||||
const removed: PeerRow[] = [];
|
||||
let claimed = 0;
|
||||
db.transaction(() => {
|
||||
for (const p of stmts.peersAll.all()) {
|
||||
if (p.owner.startsWith(prefix)) {
|
||||
if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
|
||||
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
|
||||
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
|
||||
claimed++;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]);
|
||||
if (removed.length || claimed) rebuildConf();
|
||||
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
|
||||
|
||||
const all = stmts.peersAll.all();
|
||||
return {
|
||||
present: all.filter(p => p.owner.startsWith(prefix)).map(p => p.pub_key),
|
||||
removed: removed.length,
|
||||
legacy: all.filter(p => LEGACY_OWNER.test(p.owner)).map(p => ({ pub_key: p.pub_key, owner: p.owner, ip: p.ip })),
|
||||
};
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1kb" }));
|
||||
// Сведение пиров несёт полный список ключей панели — ему лимит побольше, остальным хватает 1kb.
|
||||
const SYNC_PATH = "/api/peers/sync";
|
||||
const jsonSmall = express.json({ limit: "1kb" });
|
||||
const jsonSync = express.json({ limit: "512kb" });
|
||||
app.use((req, res, next) => (req.path === SYNC_PATH ? jsonSync : jsonSmall)(req, res, next));
|
||||
|
||||
function verifyInternalToken(token: string): boolean {
|
||||
try {
|
||||
@@ -1070,6 +1115,14 @@ app.get("/api/peers", auth, handler((_req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
|
||||
app.put(SYNC_PATH, auth, handler((req, res) => {
|
||||
const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown };
|
||||
if (typeof owner !== "string" || !PANEL_ID.test(owner)) throw new HttpError(400, "Неверный owner");
|
||||
if (!Array.isArray(keep) || keep.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
|
||||
res.json(syncOwnedPeers(owner, keep.map(checkPubKey)));
|
||||
}));
|
||||
|
||||
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)));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
+38
-12
@@ -43,18 +43,20 @@ let nodeId = 2;
|
||||
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
|
||||
|
||||
// ── 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 }];
|
||||
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, servers: [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[] }
|
||||
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
|
||||
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 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")], servers: [0, 1] }];
|
||||
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));
|
||||
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
||||
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
||||
const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
|
||||
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
|
||||
|
||||
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: "Неверный логин или пароль" }));
|
||||
@@ -78,7 +80,16 @@ app.post("/ui/nodes", (q, r) => {
|
||||
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 });
|
||||
});
|
||||
app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); });
|
||||
app.delete("/ui/nodes/:id", (q, r) => { const i = mnodes.findIndex(x => x.id === Number(q.params.id)); if (i < 0) return r.status(404).json({ error: "Не найден" }); mnodes.splice(i, 1); r.json({ success: true }); });
|
||||
app.delete("/ui/nodes/:id", (q, r) => {
|
||||
const id = Number(q.params.id), i = mnodes.findIndex(x => x.id === id); if (i < 0) return r.status(404).json({ error: "Не найден" });
|
||||
if (masters.some(m => !m.deleting && m.servers.includes(id))) return r.status(409).json({ error: "Сервер в мастер-ключах — сначала уберите его оттуда", can_force: false });
|
||||
const waiting = masters.filter(m => m.deleting?.includes(id));
|
||||
if (waiting.length && q.query.force !== "1")
|
||||
return r.status(409).json({ error: `Сервер не подтвердил, что снял пиров удалённых мастер-ключей (${waiting.map(m => m.label).join(", ")})`, can_force: true });
|
||||
for (const m of waiting) m.deleting = m.deleting!.filter(x => x !== id);
|
||||
for (const m of waiting) if (!m.deleting!.length) masters.splice(masters.indexOf(m), 1);
|
||||
mnodes.splice(i, 1); r.json({ success: true });
|
||||
});
|
||||
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
|
||||
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
|
||||
|
||||
@@ -97,28 +108,43 @@ app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q.
|
||||
app.delete("/api/users/:name", (q, r) => { const users = U_(q); 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.post("/ui/apikeys", (q, r) => { const sid = Number(q.body.server_id ?? 0); const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: sid, servers: q.body.servers ?? [sid], created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
|
||||
app.patch("/ui/apikeys/:id", (q, r) => {
|
||||
const k = apiKeys.find(x => x.id === Number(q.params.id)); if (!k) return r.status(404).json({ error: "Не найден" });
|
||||
if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" });
|
||||
k.servers = [...q.body.servers].sort(); if (!k.servers.includes(k.server_id)) k.server_id = k.servers[0];
|
||||
r.json({ id: k.id, server_id: k.server_id, servers: k.servers });
|
||||
});
|
||||
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: [] };
|
||||
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
|
||||
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.patch("/ui/masterkeys/:id", (q, r) => {
|
||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); }
|
||||
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.delete("/ui/masterkeys/:id", (q, r) => {
|
||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
const pending = m.servers.filter(id => id !== 0 && !mnodes.find(n => n.id === id)?.online);
|
||||
if (pending.length) { m.deleting = pending; m.devs = []; } else masters.splice(masters.indexOf(m), 1);
|
||||
r.json({ success: true, id: m.id, pending });
|
||||
});
|
||||
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.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : 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.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
|
||||
|
||||
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
|
||||
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
|
||||
|
||||
+40
-9
@@ -138,6 +138,26 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
|
||||
const exists = (id: number) => (id === 0 ? LOCAL : q.byId.get(id) !== undefined);
|
||||
|
||||
/** Есть ли сейчас связь с сервером (локальный считаем доступным всегда — до первого отказа awg-ctrl). */
|
||||
const isOnline = (id: number) => (id === 0 ? LOCAL : conns.has(id));
|
||||
|
||||
/** Все серверы реестра: локальный (если есть) и ноды. */
|
||||
const ids = (): number[] => [...(LOCAL ? [0] : []), ...(q.all.all() as NodeRow[]).map(n => n.id)];
|
||||
|
||||
// Подписчики на «сервер снова на связи» — подписка по этому сигналу догоняет пропущенное.
|
||||
const onlineHandlers: ((id: number) => void)[] = [];
|
||||
const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); };
|
||||
|
||||
// Подписчики на «сервер удаляют». beforeDelete — проверка: может запретить удаление и не
|
||||
// меняет ui.db (запретить может и следующий подписчик). forcible — запрет снимается
|
||||
// подтверждением оператора (force): сервер не всё успел подтвердить, а не используется.
|
||||
// afterDelete — уборка своих ссылок на сервер, когда он уже удалён.
|
||||
type DeleteVeto = { error: string; forcible: boolean } | null;
|
||||
const deleteHandlers: ((id: number, force: boolean) => Promise<DeleteVeto>)[] = [];
|
||||
const beforeDelete = (cb: (id: number, force: boolean) => Promise<DeleteVeto>) => { deleteHandlers.push(cb); };
|
||||
const afterHandlers: ((id: number) => void)[] = [];
|
||||
const afterDelete = (cb: (id: number) => void) => { afterHandlers.push(cb); };
|
||||
|
||||
/** Сервер по умолчанию: локальный, а без него — первая нода. */
|
||||
function defaultId(): number {
|
||||
if (LOCAL) return 0;
|
||||
@@ -216,6 +236,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
ws.send(JSON.stringify({ t: "ready" }));
|
||||
console.log(`nodes: «${row.name}» (#${row.id}) подключена${joining ? " (первое подключение)" : ""}`);
|
||||
void refreshHealth(row.id);
|
||||
for (const cb of onlineHandlers) { try { cb(row.id); } catch { /* подписчик сам разберётся */ } }
|
||||
}
|
||||
|
||||
ws.on("message", raw => {
|
||||
@@ -241,7 +262,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (LOCAL) void refreshHealth(0);
|
||||
if (LOCAL) {
|
||||
void refreshHealth(0);
|
||||
for (const cb of onlineHandlers) { try { cb(0); } catch { /* подписчик сам разберётся */ } }
|
||||
}
|
||||
if (!NODE_PORT || !tls) return;
|
||||
const server = https.createServer(tls);
|
||||
const wss = new WebSocketServer({ server, path: "/node/v1", maxPayload: 4 * 1024 * 1024 });
|
||||
@@ -325,21 +349,28 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
res.json({ id, name });
|
||||
});
|
||||
|
||||
router.delete("/:id", (req: Request, res: Response) => {
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
const id = idOf(req);
|
||||
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
|
||||
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
// Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе.
|
||||
const used = (["api_keys", "master_keys"] as const).some(t => {
|
||||
try { return (uidb.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE server_id = ?`).get(id) as { n: number }).n > 0; }
|
||||
catch { return false; }
|
||||
});
|
||||
if (used) { res.status(409).json({ error: "К серверу привязаны ключи API или мастер-ключи — сначала удалите их" }); return; }
|
||||
// Ключи API и мастер-ключи проверяют их владельцы в своих обработчиках (beforeDelete).
|
||||
const force = req.query.force === "1";
|
||||
for (const cb of deleteHandlers) {
|
||||
let veto: DeleteVeto;
|
||||
try { veto = await cb(id, force); } catch { veto = { error: "Не удалось подготовить сервер к удалению", forcible: true }; }
|
||||
if (veto) { res.status(409).json({ error: veto.error, can_force: veto.forcible && !force }); return; }
|
||||
}
|
||||
if (!q.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const c = conns.get(id);
|
||||
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
|
||||
q.delete.run(id);
|
||||
// Хвосты подписки для этого сервера больше некому доделать.
|
||||
for (const t of ["peer_tombstones", "server_profiles"]) {
|
||||
try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ }
|
||||
}
|
||||
for (const cb of afterHandlers) { try { cb(id); } catch (e) { console.warn(`nodes: уборка после удаления #${id}: ${(e as Error).message}`); } }
|
||||
res.json({ success: true, id });
|
||||
});
|
||||
|
||||
return { router, ctrlFor, exists, defaultId, start };
|
||||
return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, afterDelete, defaultId, start };
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+98
-13
@@ -73,17 +73,56 @@ uidb.exec(`
|
||||
server_id INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER
|
||||
)
|
||||
);
|
||||
-- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах
|
||||
-- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users,
|
||||
-- всегда входит в набор.
|
||||
CREATE TABLE IF NOT EXISTS api_key_servers (
|
||||
key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||
server_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (key_id, server_id)
|
||||
);
|
||||
-- Ключи со времён одного сервера: набор — их server_id.
|
||||
INSERT OR IGNORE INTO api_key_servers (key_id, server_id)
|
||||
SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers);
|
||||
`);
|
||||
|
||||
const keyStmts = {
|
||||
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
|
||||
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
|
||||
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
|
||||
byId: uidb.prepare<[number]>("SELECT * FROM api_keys WHERE id = ?"),
|
||||
setDefault: uidb.prepare<[number, number]>("UPDATE api_keys SET server_id = ? WHERE id = ?"),
|
||||
serversOf: uidb.prepare<[number]>("SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id"),
|
||||
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)"),
|
||||
serversClear: uidb.prepare<[number]>("DELETE FROM api_key_servers WHERE key_id = ?"),
|
||||
keysOnServer: uidb.prepare<[number]>(
|
||||
"SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?"),
|
||||
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
|
||||
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
|
||||
};
|
||||
|
||||
const keyServers = (id: number) => (keyStmts.serversOf.all(id) as { server_id: number }[]).map(r => r.server_id);
|
||||
|
||||
// Набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в наборе, иначе —
|
||||
// наименьший из набора.
|
||||
function setKeyServers(id: number, ids: number[]) {
|
||||
const row = keyStmts.byId.get(id) as ApiKeyRow;
|
||||
uidb.transaction(() => {
|
||||
keyStmts.serversClear.run(id);
|
||||
for (const sid of ids) keyStmts.serverAdd.run(id, sid);
|
||||
if (!ids.includes(row.server_id)) keyStmts.setDefault.run(ids[0], id);
|
||||
})();
|
||||
}
|
||||
|
||||
// Набор серверов из тела запроса: непустой, без повторов, только существующие.
|
||||
function parseKeyServers(v: unknown): number[] | string {
|
||||
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
|
||||
const ids = [...new Set(v)];
|
||||
if (!ids.every(id => Number.isInteger(id) && nodes.exists(id as number))) return "Такого сервера нет";
|
||||
return (ids as number[]).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function hashKey(key: string): string {
|
||||
return crypto.createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
@@ -173,26 +212,43 @@ app.get("/ui/brand", (_req: Request, res: Response) => {
|
||||
|
||||
// Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут.
|
||||
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
|
||||
res.json({ keys: keyStmts.list.all() });
|
||||
res.json({ keys: (keyStmts.list.all() as ApiKeyRow[]).map(k => ({ ...k, servers: keyServers(k.id) })) });
|
||||
});
|
||||
|
||||
// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию.
|
||||
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
|
||||
const { label, server_id } = req.body as { label?: string; server_id?: number };
|
||||
const { label, server_id, servers } = req.body as { label?: string; server_id?: number; servers?: unknown };
|
||||
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
|
||||
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
|
||||
}
|
||||
const sid = server_id === undefined ? nodes.defaultId() : Number(server_id);
|
||||
if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; }
|
||||
const ids = parseKeyServers(servers ?? [server_id === undefined ? nodes.defaultId() : Number(server_id)]);
|
||||
if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
|
||||
const sid = server_id !== undefined && ids.includes(Number(server_id)) ? Number(server_id) : ids[0];
|
||||
const { key, hash, prefix } = genApiKey();
|
||||
const info = keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000));
|
||||
let id = 0;
|
||||
uidb.transaction(() => {
|
||||
id = Number(keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000)).lastInsertRowid);
|
||||
for (const s of ids) keyStmts.serverAdd.run(id, s);
|
||||
})();
|
||||
// Открытое значение отдаётся один раз — дальше в БД только хэш.
|
||||
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
|
||||
res.status(201).json({ id, label, prefix, key, server_id: sid, servers: ids });
|
||||
});
|
||||
|
||||
// Сменить набор серверов, не перевыпуская ключ.
|
||||
app.patch("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || !keyStmts.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const ids = parseKeyServers((req.body as { servers?: unknown } | undefined)?.servers);
|
||||
if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
|
||||
setKeyServers(id, ids);
|
||||
const row = keyStmts.byId.get(id) as ApiKeyRow;
|
||||
res.json({ id, server_id: row.server_id, servers: ids });
|
||||
});
|
||||
|
||||
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
|
||||
keyStmts.delete.run(id);
|
||||
uidb.transaction(() => { keyStmts.serversClear.run(id); keyStmts.delete.run(id); })();
|
||||
res.json({ success: true, id });
|
||||
});
|
||||
|
||||
@@ -214,7 +270,7 @@ async function proxy(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
|
||||
interface ExtRequest extends Request { apiKey?: ApiKeyRow; servers?: number[]; serverId?: number; }
|
||||
|
||||
function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
const key = (req.headers["x-api-key"] as string) ?? "";
|
||||
@@ -223,6 +279,18 @@ function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||
if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
|
||||
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
|
||||
(req as ExtRequest).apiKey = row;
|
||||
(req as ExtRequest).servers = keyServers(row.id);
|
||||
next();
|
||||
}
|
||||
|
||||
// Сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка — сервер ключа по
|
||||
// умолчанию. Сервер вне набора — как несуществующий.
|
||||
function pickKeyServer(req: Request, res: Response, next: NextFunction) {
|
||||
const r = req as ExtRequest;
|
||||
const raw = req.headers["x-server-id"];
|
||||
const id = raw === undefined ? r.apiKey!.server_id : Number(raw);
|
||||
if (!Number.isInteger(id) || !r.servers!.includes(id)) { res.status(404).json({ error: "Сервер не найден" }); return; }
|
||||
r.serverId = id;
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -243,8 +311,9 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
|
||||
const ext = express.Router();
|
||||
ext.use(requireApiKey);
|
||||
|
||||
// Ключ привязан к server_id — все вызовы уходят на его сервер.
|
||||
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0);
|
||||
// vpn://-пользователи живут на одном сервере — на выбранном pickKeyServer.
|
||||
ext.use("/users", pickKeyServer);
|
||||
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).serverId ?? 0);
|
||||
|
||||
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
|
||||
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
|
||||
@@ -289,10 +358,26 @@ app.use("/api/v1", ext);
|
||||
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
|
||||
app.use("/ui/nodes", requireAuth, nodes.router);
|
||||
|
||||
// sen://-подписка пока обслуживает один сервер — по умолчанию. Несколько нод — следующий шаг.
|
||||
const sub = createSub({ uidb, ctrl: (m, p, b) => nodes.ctrlFor(nodes.defaultId())(m, p, b), baseDir: __dirname });
|
||||
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
|
||||
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
|
||||
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
|
||||
app.use("/ui/devices", requireAuth, sub.devices);
|
||||
// Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован
|
||||
// на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже.
|
||||
ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? []));
|
||||
|
||||
// Сервер удаляют из реестра: ключ API, для которого он единственный, — повод отказать
|
||||
// (ключ остался бы без серверов); у остальных он убирается из набора, когда сервер удалён.
|
||||
type KeyOnServer = { id: number; label: string; server_id: number; n: number };
|
||||
nodes.beforeDelete(async id => {
|
||||
const only = (keyStmts.keysOnServer.all(id) as KeyOnServer[]).filter(k => k.n === 1).map(k => k.label);
|
||||
return only.length
|
||||
? { error: `Это единственный сервер ключей API: ${only.join(", ")} — сначала удалите их или добавьте им другой сервер`, forcible: false }
|
||||
: null;
|
||||
});
|
||||
nodes.afterDelete(id => {
|
||||
for (const k of keyStmts.keysOnServer.all(id) as KeyOnServer[]) setKeyServers(k.id, keyServers(k.id).filter(s => s !== id));
|
||||
});
|
||||
|
||||
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
|
||||
|
||||
|
||||
@@ -732,3 +732,23 @@ tbody td:last-child { padding-right: 20px; }
|
||||
}
|
||||
.qr-img { width: 100%; height: auto; aspect-ratio: 1; }
|
||||
}
|
||||
|
||||
/* Выбор набора серверов галочками (components/ServerPicker): серверы мастер-ключа,
|
||||
видимость API-ключа */
|
||||
.server-pick {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--surface-container);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.server-pick input { accent-color: var(--primary); margin: 0; }
|
||||
.server-pick:hover { background: var(--state-primary); }
|
||||
.server-pick--locked { cursor: default; }
|
||||
.server-pick--locked:hover { background: var(--surface-container); }
|
||||
.server-pick-dot { padding: 0; background: none; }
|
||||
.server-pick-name { font-weight: 500; }
|
||||
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
|
||||
.server-pick-ip { font-family: var(--mono); margin-left: auto; }
|
||||
|
||||
+9
-1
@@ -102,7 +102,15 @@ export default function App() {
|
||||
const deleteNode = useCallback(async (n: NodeInfo) => {
|
||||
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
|
||||
try {
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
|
||||
try {
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
|
||||
} catch (e) {
|
||||
// Сервер не подтвердил, что снял пиров удалённых мастер-ключей (не в сети) —
|
||||
// панель объясняет, что останется, и удаляет только с согласия.
|
||||
const data = axios.isAxiosError(e) ? e.response?.data : undefined;
|
||||
if (!data?.can_force || !confirm(`${data.error}\n\nВсё равно удалить «${n.name}»?`)) throw e;
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}?force=1`, token);
|
||||
}
|
||||
showMsg('Сервер удалён');
|
||||
await loadNodes(token);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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 { type NodeInfo, genLabel } from '../lib/shared';
|
||||
|
||||
// Выбор набора серверов галочками: серверы мастер-ключа, видимость API-ключа. Набор не
|
||||
// бывает пустым — последний отмеченный сервер снять нельзя. onChange получает новый набор
|
||||
// и сервер, который переключили.
|
||||
export default function ServerPicker({ nodes, selected, onChange }: {
|
||||
nodes: NodeInfo[];
|
||||
selected: number[];
|
||||
onChange: (next: number[], node: NodeInfo, added: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map(n => {
|
||||
const on = selected.includes(n.id);
|
||||
const last = on && selected.length === 1;
|
||||
const h = n.health;
|
||||
return (
|
||||
<label className={`server-pick${last ? ' server-pick--locked' : ''}`} key={n.id}>
|
||||
<input type="checkbox" checked={on} disabled={last}
|
||||
onChange={() => onChange(on ? selected.filter(id => id !== n.id) : [...selected, n.id], n, !on)} />
|
||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<span className="server-pick-name">{h?.server || n.name}</span>
|
||||
{h?.ip && <code className="server-pick-ip">{h.ip}</code>}
|
||||
{h?.gen && <span className="server-pick-gen">{genLabel(h.gen)}</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Имя сервера для подписей: как в сервер-баре, иначе «#id».
|
||||
export const serverName = (nodes: NodeInfo[], id: number): string => {
|
||||
const n = nodes.find(x => x.id === id);
|
||||
return n ? n.health?.server || n.name : `#${id}`;
|
||||
};
|
||||
@@ -32,7 +32,8 @@ export interface ApiKey {
|
||||
id: number;
|
||||
label: string;
|
||||
prefix: string;
|
||||
server_id: number;
|
||||
server_id: number; // сервер по умолчанию для /api/v1/users
|
||||
servers: number[]; // какие серверы видит ключ
|
||||
created_at: number;
|
||||
last_used: number | null;
|
||||
}
|
||||
|
||||
@@ -75,3 +75,18 @@ LICENSE file in the root directory of this source tree.
|
||||
@media (max-width: 640px) {
|
||||
.apikey-cards-list { display: flex; }
|
||||
}
|
||||
|
||||
/* Настройки ключа: какие серверы он видит */
|
||||
.apikey-tr-tools td { padding-top: 0; }
|
||||
.apikey-tools {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px 14px;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
|
||||
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons';
|
||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||
import './apikeys.css';
|
||||
|
||||
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
|
||||
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
|
||||
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
|
||||
// серверов — ключ будет привязываться к выбранному в сервер-баре.
|
||||
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
|
||||
// управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор
|
||||
// правится в настройках ключа.
|
||||
export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch('GET', '/ui/apikeys', token);
|
||||
setKeys(data.keys ?? []);
|
||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
} catch {
|
||||
showMsg('Ошибка загрузки ключей');
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
const setServers = useCallback(async (k: ApiKey, next: number[], n: NodeInfo, added: boolean) => {
|
||||
try {
|
||||
await apiFetch('PATCH', `/ui/apikeys/${k.id}`, token, { servers: next });
|
||||
showMsg(added ? `Ключ видит «${serverName(nodes, n.id)}»` : `«${serverName(nodes, n.id)}» убран из ключа`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
|
||||
}
|
||||
}, [token, nodes, load, showMsg]);
|
||||
|
||||
const serversText = (k: ApiKey) => (k.servers ?? [k.server_id]).map(id => serverName(nodes, id)).join(', ');
|
||||
|
||||
const toolbox = (k: ApiKey) => (
|
||||
<section className="apikey-tools">
|
||||
<h4 className="apikey-tools-title">Серверы ключа</h4>
|
||||
<p className="apikey-tools-hint">
|
||||
На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой
|
||||
выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.
|
||||
</p>
|
||||
<ServerPicker nodes={nodes} selected={k.servers ?? [k.server_id]} onChange={(next, n, added) => setServers(k, next, n, added)} />
|
||||
</section>
|
||||
);
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
|
||||
@@ -44,8 +72,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
|
||||
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
|
||||
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
@@ -68,6 +95,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
<th>#</th>
|
||||
<th>Метка</th>
|
||||
<th>Ключ</th>
|
||||
<th>Серверы</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний раз</th>
|
||||
<th></th>
|
||||
@@ -75,24 +103,36 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
|
||||
<tr><td colSpan={7} className="empty">Нет ключей</td></tr>
|
||||
) : keys.map((k, i) => (
|
||||
<tr key={k.id}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<Fragment key={k.id}>
|
||||
<tr>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{serversText(k)}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="Серверы ключа"
|
||||
aria-label="Серверы ключа"
|
||||
aria-expanded={tools === k.id}
|
||||
onClick={() => setTools(tools === k.id ? null : k.id)}
|
||||
><IcoSettings /></button>
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -110,13 +150,23 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
<span className="apikey-card-num">#{i + 1}</span>
|
||||
<span className="apikey-card-label">{k.label}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
<div className="apikey-card-btns">
|
||||
<button
|
||||
className="btn btn--tonal btn--sq"
|
||||
aria-label="Серверы ключа"
|
||||
aria-expanded={tools === k.id}
|
||||
onClick={() => setTools(tools === k.id ? null : k.id)}
|
||||
><IcoSettings /></button>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="apikey-card-prefix">{k.prefix}</code>
|
||||
<span className="apikey-card-servers">Серверы: {serversText(k)}</span>
|
||||
{tools === k.id && toolbox(k)}
|
||||
<div className="apikey-card-meta">
|
||||
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
|
||||
|
||||
@@ -3,19 +3,27 @@
|
||||
// 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 { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||
import './masterkeys.css';
|
||||
|
||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
||||
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; created_at: number }
|
||||
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: 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;
|
||||
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
||||
}
|
||||
|
||||
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
|
||||
// панель догонит, когда нода появится.
|
||||
const syncLabel = (d: Device): string =>
|
||||
d.servers_ok < d.servers_total ? ` · на ${d.servers_ok} из ${d.servers_total} серверов` : '';
|
||||
|
||||
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
|
||||
const platformLabel = (d: Device): string =>
|
||||
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
|
||||
@@ -26,7 +34,7 @@ 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 [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
|
||||
const [label, setLabel] = useState('');
|
||||
const [limit, setLimit] = useState('3');
|
||||
const [devs, setDevs] = useState<Record<number, Device[]>>({});
|
||||
@@ -40,9 +48,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
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/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
const all = await Promise.all(list.filter(k => !k.deleting).map(k =>
|
||||
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
|
||||
setDevs(Object.fromEntries(all));
|
||||
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
|
||||
@@ -57,15 +64,26 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
const create = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
await act(async () => {
|
||||
await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit) });
|
||||
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
|
||||
const ready = nodes.filter(n => !n.pending).map(n => n.id);
|
||||
await apiFetch('POST', '/ui/masterkeys', token, {
|
||||
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
|
||||
});
|
||||
setLabel('');
|
||||
}, 'Мастер-ключ создан');
|
||||
}, [label, limit, token, act]);
|
||||
}, [label, limit, nodes, token, act]);
|
||||
|
||||
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
|
||||
|
||||
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]);
|
||||
try {
|
||||
const r = await apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token);
|
||||
const pending: number[] = r.pending ?? [];
|
||||
showMsg(pending.length ? `«${k.label}» отключён, ждём подтверждения: ${serverNames(pending)}` : 'Удалён: ' + k.label);
|
||||
await load();
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}, [token, load, showMsg, serverNames]);
|
||||
|
||||
const fetchLink = useCallback(async (k: MasterKey) => {
|
||||
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
|
||||
@@ -92,6 +110,20 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
// Пока какой-то ключ ждёт серверы, время от времени проверяем, не подтвердили ли они.
|
||||
const waiting = keys.some(k => k.deleting);
|
||||
useEffect(() => {
|
||||
if (!waiting) return;
|
||||
const t = setInterval(load, 15_000);
|
||||
return () => clearInterval(t);
|
||||
}, [waiting, load]);
|
||||
|
||||
const deletingChip = (k: MasterKey) => (
|
||||
<span className="chip chip--offline" title="Ключ уже не работает; строка исчезнет, когда серверы подтвердят, что сняли его устройства">
|
||||
<span className="chip-dot" />Удаляется — ждёт: {serverNames(k.deleting ?? [])}
|
||||
</span>
|
||||
);
|
||||
|
||||
const actions = (k: MasterKey) => (
|
||||
<div className="actions">
|
||||
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
|
||||
@@ -146,20 +178,14 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён.
|
||||
TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[]
|
||||
уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */}
|
||||
{/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку —
|
||||
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
|
||||
<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>
|
||||
<h4 className="mk-box-title">Серверы ключа</h4>
|
||||
<ServerPicker nodes={nodes} selected={k.servers} onChange={(next, n, added) =>
|
||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
|
||||
added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />
|
||||
<p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -181,8 +207,13 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
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" title="Новый PSK" aria-label="Новый PSK" onClick={async () => {
|
||||
try {
|
||||
const r = await apiFetch('POST', `/ui/devices/${d.id}/psk`, token);
|
||||
showMsg(r.pending ? `PSK обновлён; ещё на ${r.pending} — когда сервер будет на связи` : 'PSK обновлён');
|
||||
await load();
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}}><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), 'Устройство отозвано');
|
||||
@@ -205,7 +236,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<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>
|
||||
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
|
||||
</div>
|
||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
||||
@@ -251,6 +282,12 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
|
||||
) : keys.map(k => {
|
||||
if (k.deleting) return (
|
||||
<tr key={k.id} className="mk-tr-master mk-last">
|
||||
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
|
||||
<td colSpan={8}>{deletingChip(k)}</td>
|
||||
</tr>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const open = !folded.has(k.id);
|
||||
@@ -276,7 +313,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<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>
|
||||
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
|
||||
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td>
|
||||
<td></td>
|
||||
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
||||
</tr>
|
||||
@@ -297,6 +335,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
|
||||
<ul className="mk-tree">
|
||||
{keys.map(k => {
|
||||
if (k.deleting) return (
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}</span>
|
||||
{deletingChip(k)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const isFolded = folded.has(k.id);
|
||||
|
||||
@@ -149,26 +149,6 @@ LICENSE file in the root directory of this source tree.
|
||||
.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%; }
|
||||
|
||||
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
||||
|
||||
+656
-187
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+167
-15
@@ -100,6 +100,9 @@ const SERVICES = {
|
||||
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",
|
||||
// Публичный адрес панели в sen://-ссылках и endpoints. Пусто — адрес локального
|
||||
// сервера (у панели без своего VPN — тот, по которому к ней зашли).
|
||||
SUB_HOST: process.env.SUB_HOST ?? "",
|
||||
// Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в
|
||||
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
|
||||
// core без собственного VPN: awg-ctrl рядом не нужен.
|
||||
@@ -141,7 +144,13 @@ type SvcName = keyof typeof SERVICES;
|
||||
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
|
||||
const IS_NODE = !!process.env.CORE_HOST;
|
||||
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
|
||||
const ALL: SvcName[] = IS_NODE ? ["awgctrl", "agent"] : NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
|
||||
// Нода со своей панелью: работающий сервер «панель + VPN» подключили к главной панели, не
|
||||
// выключая его собственную (join без --no-panel). Обе панели ходят в один awg-ctrl — он и
|
||||
// есть источник правды, поэтому друг другу они не мешают. У ноды, поставленной install.sh,
|
||||
// панели нет (UI_PASS не задан).
|
||||
const NODE_PANEL = IS_NODE && !!process.env.UI_PASS && (process.env.NODE_PANEL ?? "on").toLowerCase() !== "off";
|
||||
const ALL: SvcName[] = IS_NODE ? (NODE_PANEL ? ["awgctrl", "ui", "agent"] : ["awgctrl", "agent"])
|
||||
: NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
|
||||
|
||||
function readPid(name: SvcName): number | null {
|
||||
const { pidFile } = SERVICES[name];
|
||||
@@ -319,12 +328,12 @@ async function startAll() {
|
||||
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
|
||||
|
||||
await start("awgctrl");
|
||||
const next: SvcName = IS_NODE ? "agent" : "ui";
|
||||
const rest = ALL.filter(n => n !== "awgctrl");
|
||||
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
|
||||
console.error(` ${grey("■")} awg-ctrl не отвечает — ${SERVICES[next].label} не запущен (см. ${SERVICES.awgctrl.logFile})`);
|
||||
console.error(` ${grey("■")} awg-ctrl не отвечает — ${rest.map(n => SERVICES[n].label).join(", ")} не запущен (см. ${SERVICES.awgctrl.logFile})`);
|
||||
return;
|
||||
}
|
||||
await start(next);
|
||||
for (const n of rest) await start(n);
|
||||
}
|
||||
|
||||
async function stopAll() { for (const n of ALL) await stop(n); }
|
||||
@@ -347,11 +356,12 @@ function restartFresh(): void {
|
||||
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
// `join <awgjoin://…>`: превратить эту установку в ноду. Панель (awg-ui) на этой машине
|
||||
// останавливается, вместо неё запускается агент; awg-ctrl и пользователи остаются как были.
|
||||
async function joinCore(link?: string) {
|
||||
if (!link?.startsWith("awgjoin://")) {
|
||||
console.error(" Usage: awg-ctrl join <awgjoin://…> (ссылку даёт панель: «Добавить сервер»)");
|
||||
// `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как ноду. Рядом
|
||||
// запускается агент; awg-ctrl и пользователи остаются как были. Своя панель (awg-ui) продолжает
|
||||
// работать, если она здесь есть, — с --no-panel она останавливается.
|
||||
async function joinCore(link?: string, flag?: string) {
|
||||
if (!link?.startsWith("awgjoin://") || (flag !== undefined && flag !== "--no-panel")) {
|
||||
console.error(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)");
|
||||
process.exit(1);
|
||||
}
|
||||
let j: { v?: number; h?: string; p?: number; pin?: string; n?: number; s?: string };
|
||||
@@ -364,23 +374,164 @@ async function joinCore(link?: string) {
|
||||
if (!ok) { console.error(" Ссылка неверного формата или от другой версии панели"); process.exit(1); }
|
||||
if (IS_NODE && !await confirmYes(` Эта нода уже подключена к ${process.env.CORE_HOST}. Перепривязать?`)) return;
|
||||
|
||||
const keepPanel = flag !== "--no-panel" && !!process.env.UI_PASS;
|
||||
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
||||
const set: Record<string, string> = {
|
||||
CORE_HOST: j.h!, CORE_PORT: String(j.p), CORE_PIN: j.pin!, NODE_ID: String(j.n), JOIN_SECRET: j.s!,
|
||||
NODE_PANEL: keepPanel ? "on" : "off",
|
||||
};
|
||||
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
|
||||
writeFileSync(envFile, content, { mode: 0o600 });
|
||||
|
||||
await stop("ui"); // панель на ноде не нужна
|
||||
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}`);
|
||||
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
|
||||
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
|
||||
const dropPeers = !keepPanel && process.env.UI_PASS ? await askDropPanelPeers() : false;
|
||||
if (!keepPanel) await stop("ui");
|
||||
if (dropPeers) await dropArea(localPanelId()!);
|
||||
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
|
||||
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
|
||||
restartFresh();
|
||||
}
|
||||
|
||||
// ── Пиры подписки (sen://) на локальном awg-ctrl ──────────────────────────────
|
||||
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели.
|
||||
// У ноды со своей панелью на одном awg-ctrl две области (своя и core). Пиры без области
|
||||
// («m1/d2») выданы до областей. Снимает их awg-ctrl по полному списку от панели
|
||||
// (PUT /api/peers/sync); пустой список = снять всю область. Здесь — ручной рычаг для
|
||||
// оператора: панель выключена или core пропал насовсем. По времени ничего не удаляется.
|
||||
const PANEL_ID_FILE = path.join(ROOT, "awg-ui", "panel.id");
|
||||
const AREA_RE = /^[a-z0-9]{8,16}$/;
|
||||
const LEGACY_OWNER = /^m\d+\/d\d+$/;
|
||||
|
||||
function localPanelId(): string | null {
|
||||
try { const id = readFileSync(PANEL_ID_FILE, "utf8").trim(); return AREA_RE.test(id) ? id : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Тот же короткоживущий токен, что awg-ui и awg-agent шлют awg-ctrl (Ed25519, 60 с).
|
||||
function mintInternalToken(): string {
|
||||
const key = crypto.createPrivateKey(readFileSync(SERVICES.ui.env.INTERNAL_AUTH_KEY_FILE));
|
||||
const t = Math.floor(Date.now() / 1000);
|
||||
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
|
||||
const p = Buffer.from(JSON.stringify({ iss: "awg-cli", iat: t, exp: t + 60 })).toString("base64url");
|
||||
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), key).toString("base64url")}`;
|
||||
}
|
||||
|
||||
function ctrlCall(method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = http.request({
|
||||
host: "127.0.0.1", port: Number(SERVICES.awgctrl.env.PORT), method, path: urlPath, timeout: 30_000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json",
|
||||
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
}, res => {
|
||||
let raw = "";
|
||||
res.on("data", c => { raw += c; });
|
||||
res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, data: null }); } });
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("timeout")));
|
||||
req.end(payload);
|
||||
});
|
||||
}
|
||||
|
||||
interface CtrlPeer { pub_key: string; ip: string; owner: string; online: boolean }
|
||||
|
||||
async function listPeers(): Promise<CtrlPeer[]> {
|
||||
const r = await ctrlCall("GET", "/api/peers");
|
||||
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
|
||||
return r.data.peers as CtrlPeer[];
|
||||
}
|
||||
|
||||
// Область пира: id панели, «legacy» (до областей) или null (чужой формат — не наш).
|
||||
function areaOf(owner: string): string | null {
|
||||
const head = owner.split("/")[0];
|
||||
if (owner.includes("/") && AREA_RE.test(head)) return head;
|
||||
return LEGACY_OWNER.test(owner) ? "legacy" : null;
|
||||
}
|
||||
|
||||
const peersWord = (n: number) =>
|
||||
n % 10 === 1 && n % 100 !== 11 ? "пир" : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? "пира" : "пиров";
|
||||
|
||||
async function askDropPanelPeers(): Promise<boolean> {
|
||||
const own = localPanelId();
|
||||
if (!own) return false;
|
||||
let n: number;
|
||||
try { n = (await listPeers()).filter(p => areaOf(p.owner) === own).length; }
|
||||
catch (e) { console.warn(` ${dim(`Не удалось посмотреть пиров подписки: ${(e as Error).message}`)}`); return false; }
|
||||
if (n === 0) return false;
|
||||
|
||||
const preset = (process.env.NODE_PEERS ?? "").toLowerCase();
|
||||
if (preset === "keep" || preset === "drop") return preset === "drop";
|
||||
console.log(`\n На этой панели ${n} ${peersWord(n)} подписки (устройства мастер-ключей). Без панели ими некому управлять:`);
|
||||
console.log(` 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin`);
|
||||
console.log(` 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)`);
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const a = await new Promise<string>(r => rl.question(" Выбор [1]: ", r));
|
||||
rl.close();
|
||||
return a.trim() === "2";
|
||||
}
|
||||
|
||||
// Снять всю область: для панели — пустым списком через sync, для legacy — по одному.
|
||||
async function dropArea(area: string): Promise<void> {
|
||||
try {
|
||||
if (area === "legacy") {
|
||||
const legacy = (await listPeers()).filter(p => areaOf(p.owner) === "legacy");
|
||||
for (const p of legacy) await ctrlCall("DELETE", `/api/peers/${Buffer.from(p.pub_key, "base64").toString("base64url")}`);
|
||||
console.log(` ${green("✓")} Снято пиров без области: ${legacy.length}`);
|
||||
return;
|
||||
}
|
||||
const r = await ctrlCall("PUT", "/api/peers/sync", { owner: area, keep: [] });
|
||||
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
|
||||
console.log(` ${green("✓")} Снято пиров области ${area}: ${r.data.removed}`);
|
||||
} catch (e) {
|
||||
console.error(` Не удалось снять пиров: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function peersCmd(sub?: string, arg?: string) {
|
||||
const own = localPanelId();
|
||||
let peers: CtrlPeer[];
|
||||
try { peers = await listPeers(); }
|
||||
catch (e) { console.error(` awg-ctrl недоступен: ${(e as Error).message}`); process.exit(1); }
|
||||
const areas = new Map<string, CtrlPeer[]>();
|
||||
for (const p of peers) { const a = areaOf(p.owner); if (a) areas.set(a, [...(areas.get(a) ?? []), p]); }
|
||||
|
||||
if (sub === undefined) {
|
||||
if (!areas.size) { console.log(" Пиров подписки (sen://) на этом сервере нет"); return; }
|
||||
console.log("\n Пиры подписки (sen://) на этом сервере:\n");
|
||||
for (const [a, list] of areas) {
|
||||
const mark = a === own ? "эта панель" : a === "legacy" ? "без области (выданы до областей)" : "";
|
||||
console.log(` ${bold(a.padEnd(16))} ${String(list.length).padStart(4)} ${peersWord(list.length).padEnd(5)}` +
|
||||
` онлайн ${list.filter(p => p.online).length} ${dim(mark)}`);
|
||||
}
|
||||
console.log(`\n ${dim("Снять область целиком: awg-ctrl peers drop <область>")}`);
|
||||
return;
|
||||
}
|
||||
if (sub !== "drop" || !arg || !(arg === "legacy" || AREA_RE.test(arg))) {
|
||||
console.error(" Usage: awg-ctrl peers [drop <область|legacy>]");
|
||||
process.exit(1);
|
||||
}
|
||||
const n = areas.get(arg)?.length ?? 0;
|
||||
if (!n) { console.log(` В области ${arg} пиров нет`); return; }
|
||||
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
|
||||
if (arg === own && isRunning("ui")) {
|
||||
console.error(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ${dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")}`);
|
||||
console.log(` ${dim("сперва удалите сервер в ней (или мастер-ключи).")}`);
|
||||
if (!await confirmYes(` Снять ${n} ${peersWord(n)} области ${arg}? Их устройства потеряют доступ к этому серверу.`)) return;
|
||||
await dropArea(arg);
|
||||
}
|
||||
|
||||
async function unjoinCore() {
|
||||
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
|
||||
await stop("agent");
|
||||
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
||||
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET"]) content = removeEnvVar(content, k);
|
||||
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"]) content = removeEnvVar(content, k);
|
||||
writeFileSync(envFile, content, { mode: 0o600 });
|
||||
console.log(` ${green("✓")} Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart`);
|
||||
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
|
||||
@@ -489,9 +640,9 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…>|unjoin>";
|
||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]>";
|
||||
|
||||
const [,,, sub] = process.argv;
|
||||
const [,,, sub, arg] = process.argv;
|
||||
|
||||
switch (cmd) {
|
||||
case "start": await startAll(); break;
|
||||
@@ -503,8 +654,9 @@ async function main() {
|
||||
else if (sub === "pass") await setCredentials("pass");
|
||||
else await setCredentials();
|
||||
break;
|
||||
case "join": await joinCore(sub); break;
|
||||
case "join": await joinCore(sub, arg); break;
|
||||
case "unjoin": await unjoinCore(); break;
|
||||
case "peers": await peersCmd(sub, arg); break;
|
||||
default: console.log(USAGE); process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
|
||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||
# При бампе версии правь оба места.
|
||||
VERSION="0.3.1"
|
||||
VERSION="0.3.3"
|
||||
CHANNEL="${CHANNEL:-Beta}"
|
||||
|
||||
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
|
||||
@@ -1363,6 +1363,7 @@ 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}
|
||||
SUB_HOST=${SERVER_IP}
|
||||
|
||||
# ── приём нод (несколько серверов) ────────────────────────────────────────
|
||||
NODE_PORT=${NODE_PORT}
|
||||
|
||||
Reference in New Issue
Block a user