3 Commits
Author SHA1 Message Date
maeneko eb0ec2878c Bump to Beta 0.3.3
release / release (push) Successful in 3m34s
ci / check (push) Successful in 7m12s
All seven places the version is written down: .env, the copy in
install.sh, the four package.json files and the /ui/brand example in the
README.

Deleted devices get a signed 410 revoked with an echo of the request, so
a client can tell a revoked key from a network failure; master-key peers
no longer outlive their devices; API keys can manage master keys.
2026-09-27 16:23:02 +03:00
maeneko a85f5d87b2 Manage master keys through the external API
API keys now see a set of servers instead of one, and /api/v1 gains the
master-key operations the panel already had.

- api_key_servers holds each key's servers (migrated from server_id,
  which stays the default server for /api/v1/users). /users follows
  X-Server-Id within the set; a server outside it is 404. PATCH
  /ui/apikeys/:id changes the set without reissuing the key.
- /api/v1/masterkeys: create (returns the sen:// link), list, get with
  link and devices, patch label/limit/servers, rotate, rekey, delete, and
  nested device routes (unlink, rekey, PSK). A key sees a master key only
  if all of its servers are in the key's set, so it cannot delete or
  re-point service on a server it cannot see; anything else is 404,
  servers outside the set are 403. Creating refuses with 503 before
  writing anything if the subscription cannot issue links.
- sub.ts: the panel routes and the API share one service layer, the
  panel's behaviour is unchanged.
- Node deletion: owners check in beforeDelete (no writes, a later hook
  may still veto) and clean up in afterDelete. Previously a forced delete
  could release a waiting master key and then be vetoed. A node that is
  the only server of an API key is refused; other keys just lose it.
- Panel: server checkboxes are a shared ServerPicker, used by the master
  key settings and the new API key settings row; the API keys table shows
  each key's servers.
- README: the API section covers server sets and master keys.
2026-09-27 15:59:16 +03:00
maeneko c5e1dc9b32 Stop master-key peers from outliving their devices
A deleted master key could keep letting its devices in: ui.db learns what
is on a server only from awg-ctrl's reply, and a node that executed a
request whose 504/502 reply got lost, or a device deleted while a
reconcile pass was in flight, left a peer nothing remembered to remove.

- awg-ctrl: PUT /api/peers/sync takes a panel's full list of wanted keys
  and removes every other peer of that panel's area. Removal is driven
  only by such an explicit list, never by time or inactivity: a panel
  that is offline sends nothing, so nothing is deleted. Registered before
  PUT /api/peers/:pub, with its own 512 kB body limit.
- Every panel has a stable id (ui.db, mirrored to awg-ui/panel.id) and
  adds peers as "<panel>/m<key>/d<device>", so two panels on one awg-ctrl
  never touch each other's peers. Legacy "m1/d2" peers are claimed only
  when their key is in the panel's list.
- Each reconcile pass ends with the sync, built from ui.db at that moment.
  Its reply also self-heals: a device whose key is missing on a server
  (a rekey rolled back after a lost reply) is added again.
- Deleting a master key stops it at once but keeps it listed as
  "Удаляется — ждёт: …" until every server confirms, instead of saying
  "deleted" while an offline node still lets the devices in.
- Deleting a node reconciles it first; if it is offline or has not
  confirmed a deleted key, it takes ?force=1 and a warning. Tombstones
  are no longer dropped silently.
- /sub/v1: an unknown device gets a signed 410 revoked that does not count
  towards the failure limit (dead clients behind one node NAT used to
  lock everyone out), every response echoes the request's ts and device,
  and 401 reasons and the first 429 per window are logged. config and rev
  are unchanged, byte-identical on one server.
- CLI: `awg-ctrl peers [drop <area|legacy>]` lists and removes
  subscription peers by area; `join --no-panel` asks whether to keep or
  drop the panel's own peers (NODE_PEERS=keep|drop).
2026-09-27 15:30:53 +03:00
22 changed files with 949 additions and 265 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting BRAND=Forgetting
CHANNEL=Beta CHANNEL=Beta
VERSION=0.3.2 VERSION=0.3.3
+1
View File
@@ -4,6 +4,7 @@
**/node_modules/ **/node_modules/
**/dist/ **/dist/
cli/cli.env cli/cli.env
awg-ui/panel.id
logs/ logs/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
+53 -3
View File
@@ -83,6 +83,39 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить» `"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_` | | `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`) — вне Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне
каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про
внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией. внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией.
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` и Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем
`DELETE /ui/apikeys/:id`. `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.2" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина | | `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 /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
@@ -156,6 +190,22 @@ base64url-декодировать, отбросить первые 4 байта
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс 3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
поднимается обратно на 2.0. поднимается обратно на 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 ```bash
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-agent", "name": "awg-agent",
"version": "0.3.2", "version": "0.3.3",
"private": true, "private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl", "description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": { "scripts": {
+54 -1
View File
@@ -305,6 +305,7 @@ const stmts = {
peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"), peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"),
peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? 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 = ?"), 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 = ?"), 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 = ?"), get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
all: db.prepare<[], UserRow>("SELECT * FROM users"), all: db.prepare<[], UserRow>("SELECT * FROM users"),
@@ -946,8 +947,52 @@ function removePeer(pub: string) {
logger.info("peer removed", { owner: peer.owner }); 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(); 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 { function verifyInternalToken(token: string): boolean {
try { 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) => { app.put("/api/peers/:pub", auth, handler((req, res) => {
const { pub_key } = (req.body ?? {}) as { pub_key?: string }; const { pub_key } = (req.body ?? {}) as { pub_key?: string };
res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key))); res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key)));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.3.2", "version": "0.3.3",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+27 -7
View File
@@ -43,20 +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"); 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-ключи ── // ── 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; let apiId = 1;
// ── мастер-ключи sen:// ── // ── мастер-ключи 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 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[]; servers: number[] } 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; 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 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")], servers: [0, 1] }]; 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 signPub = crypto.randomBytes(32);
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 }); 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 — ещё не подключалась). // Мок: устройство «догнано» на всех серверах ключа, кроме 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 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)); 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.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("/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: "Неверный логин или пароль" }));
@@ -80,7 +80,16 @@ app.post("/ui/nodes", (q, r) => {
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 }); 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.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.post("/awg/restart", (_q, r) => r.json({ up: true }));
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length })); app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
@@ -99,7 +108,13 @@ 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.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.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.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.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
@@ -116,7 +131,12 @@ app.patch("/ui/masterkeys/:id", (q, r) => {
}); });
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/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.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) => { app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); 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 }); r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
+21 -8
View File
@@ -148,6 +148,16 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const onlineHandlers: ((id: number) => void)[] = []; const onlineHandlers: ((id: number) => void)[] = [];
const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); }; 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 { function defaultId(): number {
if (LOCAL) return 0; if (LOCAL) return 0;
@@ -339,16 +349,18 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
res.json({ id, name }); res.json({ id, name });
}); });
router.delete("/:id", (req: Request, res: Response) => { router.delete("/:id", async (req: Request, res: Response) => {
const id = idOf(req); const id = idOf(req);
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined; const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
if (!n) { res.status(404).json({ error: "Не найден" }); return; } if (!n) { res.status(404).json({ error: "Не найден" }); return; }
// Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе. // Ключи API и мастер-ключи проверяют их владельцы в своих обработчиках (beforeDelete).
const used = (["api_keys", "master_servers"] as const).some(t => { const force = req.query.force === "1";
try { return (uidb.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE server_id = ?`).get(id) as { n: number }).n > 0; } for (const cb of deleteHandlers) {
catch { return false; } let veto: DeleteVeto;
}); try { veto = await cb(id, force); } catch { veto = { error: "Не удалось подготовить сервер к удалению", forcible: true }; }
if (used) { res.status(409).json({ error: "К серверу привязаны ключи API или мастер-ключи — сначала удалите их" }); return; } 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); const c = conns.get(id);
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); } if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
q.delete.run(id); q.delete.run(id);
@@ -356,8 +368,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
for (const t of ["peer_tombstones", "server_profiles"]) { for (const t of ["peer_tombstones", "server_profiles"]) {
try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ } 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 }); res.json({ success: true, id });
}); });
return { router, ctrlFor, exists, isOnline, ids, onOnline, defaultId, start }; return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, afterDelete, defaultId, start };
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.3.2", "version": "0.3.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+96 -11
View File
@@ -73,17 +73,56 @@ uidb.exec(`
server_id INTEGER NOT NULL DEFAULT 0, server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
last_used INTEGER 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 = { const keyStmts = {
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"), 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 (?, ?, ?, ?, ?)"), 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 = ?"), 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 = ?"), byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"), 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 { function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex"); 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 не идут. // Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут.
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => { 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) => { 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)) { if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return; res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
} }
const sid = server_id === undefined ? nodes.defaultId() : Number(server_id); const ids = parseKeyServers(servers ?? [server_id === undefined ? nodes.defaultId() : Number(server_id)]);
if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; } 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 { 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) => { app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; } 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 }); 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) { function requireApiKey(req: Request, res: Response, next: NextFunction) {
const key = (req.headers["x-api-key"] as string) ?? ""; 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; } if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id); keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
(req as ExtRequest).apiKey = row; (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(); next();
} }
@@ -243,8 +311,9 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
const ext = express.Router(); const ext = express.Router();
ext.use(requireApiKey); ext.use(requireApiKey);
// Ключ привязан к server_id — все вызовы уходят на его сервер. // vpn://-пользователи живут на одном сервере — на выбранном pickKeyServer.
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0); ext.use("/users", pickKeyServer);
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).serverId ?? 0);
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не // Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ // отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
@@ -293,6 +362,22 @@ app.use("/ui/nodes", requireAuth, nodes.router);
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname }); const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys); app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices); 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); app.use(["/api", "/health", "/awg"], requireAuth, proxy);
+20
View File
@@ -732,3 +732,23 @@ tbody td:last-child { padding-right: 20px; }
} }
.qr-img { width: 100%; height: auto; aspect-ratio: 1; } .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; }
+8
View File
@@ -101,8 +101,16 @@ export default function App() {
const deleteNode = useCallback(async (n: NodeInfo) => { const deleteNode = useCallback(async (n: NodeInfo) => {
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return; if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
try {
try { try {
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token); 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('Сервер удалён'); showMsg('Сервер удалён');
await loadNodes(token); await loadNodes(token);
} catch (e) { } catch (e) {
+41
View File
@@ -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}`;
};
+2 -1
View File
@@ -32,7 +32,8 @@ export interface ApiKey {
id: number; id: number;
label: string; label: string;
prefix: string; prefix: string;
server_id: number; server_id: number; // сервер по умолчанию для /api/v1/users
servers: number[]; // какие серверы видит ключ
created_at: number; created_at: number;
last_used: number | null; last_used: number | null;
} }
+15
View File
@@ -75,3 +75,18 @@ LICENSE file in the root directory of this source tree.
@media (max-width: 640px) { @media (max-width: 640px) {
.apikey-cards-list { display: flex; } .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); }
+59 -9
View File
@@ -1,29 +1,57 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react'; import { Fragment, useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared'; import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons'; import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import './apikeys.css'; import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается // Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App. // один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список // Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
// серверов — ключ будет привязываться к выбранному в сервер-баре. // управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор
// правится в настройках ключа.
export default function ApiKeysPage({ token, showMsg }: PageProps) { export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]); const [keys, setKeys] = useState<ApiKey[]>([]);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null); const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const data = await apiFetch('GET', '/ui/apikeys', token); const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []); setKeys(data.keys ?? []);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch { } catch {
showMsg('Ошибка загрузки ключей'); showMsg('Ошибка загрузки ключей');
} }
}, [token, 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 () => { const createKey = useCallback(async () => {
if (!label.trim()) return; if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 }); 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 ( return (
<> <>
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ. {/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
<div className="toolbar"> <div className="toolbar">
<input <input
className="field" 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>
<th>Создан</th> <th>Создан</th>
<th>Последний раз</th> <th>Последний раз</th>
<th></th> <th></th>
@@ -75,24 +103,36 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{keys.length === 0 ? ( {keys.length === 0 ? (
<tr><td colSpan={6} className="empty">Нет ключей</td></tr> <tr><td colSpan={7} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => ( ) : keys.map((k, i) => (
<tr key={k.id}> <Fragment key={k.id}>
<tr>
<td className="td-num">{i + 1}</td> <td className="td-num">{i + 1}</td>
<td>{k.label}</td> <td>{k.label}</td>
<td className="td-mono">{k.prefix}</td> <td className="td-mono">{k.prefix}</td>
<td>{serversText(k)}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td> <td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td> <td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions"> <td className="td-actions">
<div className="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 <button
className="btn-icon btn-icon--danger" className="btn-icon btn-icon--danger"
title="Удалить ключ" title="Удалить ключ"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)} onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button> ><IcoTrash /></button>
</div> </div>
</td> </td>
</tr> </tr>
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
</Fragment>
))} ))}
</tbody> </tbody>
</table> </table>
@@ -110,13 +150,23 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
<span className="apikey-card-num">#{i + 1}</span> <span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span> <span className="apikey-card-label">{k.label}</span>
</div> </div>
<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 <button
className="btn btn--danger btn--sq" className="btn btn--danger btn--sq"
aria-label="Удалить ключ" aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)} onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button> ><IcoTrash /></button>
</div> </div>
</div>
<code className="apikey-card-prefix">{k.prefix}</code> <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"> <div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span> <span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span> <span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
+45 -25
View File
@@ -3,14 +3,16 @@
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react'; import { Fragment, useState, useEffect, useCallback } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, genLabel, type NodeInfo, 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 { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import './masterkeys.css'; import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка // Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся // плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md. // на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: 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 { interface Device {
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; 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; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
@@ -47,7 +49,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const list: MasterKey[] = d.keys ?? []; const list: MasterKey[] = d.keys ?? [];
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
const all = await Promise.all(list.map(k => 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))); apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all)); setDevs(Object.fromEntries(all));
} catch { showMsg('Ошибка загрузки мастер-ключей'); } } catch { showMsg('Ошибка загрузки мастер-ключей'); }
@@ -71,10 +73,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, 'Мастер-ключ создан'); }, 'Мастер-ключ создан');
}, [label, limit, nodes, 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) => { const remove = useCallback(async (k: MasterKey) => {
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return; if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label); try {
}, [token, act]); 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 fetchLink = useCallback(async (k: MasterKey) => {
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token); const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
@@ -101,6 +110,20 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
useEffect(() => { if (token) load(); }, [token, load]); 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) => ( const actions = (k: MasterKey) => (
<div className="actions"> <div className="actions">
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button> <button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
@@ -159,27 +182,9 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */} пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
<section className="mk-box"> <section className="mk-box">
<h4 className="mk-box-title">Серверы ключа</h4> <h4 className="mk-box-title">Серверы ключа</h4>
{nodes.map(n => { <ServerPicker nodes={nodes} selected={k.servers} onChange={(next, n, added) =>
const on = k.servers.includes(n.id);
const last = on && k.servers.length === 1;
const h = n.health;
return (
<label className={`mk-server${last ? ' mk-server--locked' : ''}`} key={n.id}>
<input type="checkbox" checked={on} disabled={last}
onChange={() => {
const next = on ? k.servers.filter(id => id !== n.id) : [...k.servers, n.id];
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }), act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
on ? `«${h?.server || n.name}» убран из ключа` : `«${h?.server || n.name}» добавлен в ключ`); added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />
}} />
<span className={`chip chip--${n.online ? 'online' : 'offline'} mk-server-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
<span className="chip-dot" />
</span>
<span className="mk-server-name">{h?.server || n.name}</span>
{h?.ip && <code className="mk-server-ip">{h.ip}</code>}
{h?.gen && <span className="mk-server-gen">{genLabel(h.gen)}</span>}
</label>
);
})}
<p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p> <p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p>
</section> </section>
</div> </div>
@@ -277,6 +282,12 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
{keys.length === 0 ? ( {keys.length === 0 ? (
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr> <tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
) : keys.map(k => { ) : 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 list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length); const free = Math.max(0, k.device_limit - list.length);
const open = !folded.has(k.id); const open = !folded.has(k.id);
@@ -324,6 +335,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : ( {keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
<ul className="mk-tree"> <ul className="mk-tree">
{keys.map(k => { {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 list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length); const free = Math.max(0, k.device_limit - list.length);
const isFolded = folded.has(k.id); const isFolded = folded.has(k.id);
-18
View File
@@ -149,24 +149,6 @@ LICENSE file in the root directory of this source tree.
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */ .mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; } :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 { cursor: pointer; }
.mk-server:hover { background: var(--state-primary); }
.mk-server--locked { cursor: default; }
.mk-server--locked:hover { background: var(--surface-container); }
.mk-server-dot { padding: 0; background: none; }
.mk-box .tip-wrap { width: 100%; } .mk-box .tip-wrap { width: 100%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */ /* дети: вертикальная линия слева и «ножка» к каждому узлу */
+328 -142
View File
@@ -16,6 +16,14 @@
// хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить, // хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить,
// а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться // а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться
// (сразу при действии, при подключении ноды и раз в минуту). // (сразу при действии, при подключении ноды и раз в минуту).
//
// Каждый проход заканчивается сведением пиров панели (PUT /api/peers/sync): awg-ctrl
// получает полный список ключей, которые этой панели нужны на сервере, и снимает из её
// области всё остальное. Это страховка от пиров, чья судьба потерялась (ответ ноды не
// дошёл, устройство удалили посреди прохода). Область — id панели в owner пира
// («<panel>/m1/d2»): у ноды может быть своя панель на том же awg-ctrl, чужих не трогаем.
// Удаляем только по такому списку, никогда по времени: панель не на связи — ничего не
// происходит, неактивные устройства никто не снимает.
import crypto from "crypto"; import crypto from "crypto";
import fs from "fs"; import fs from "fs";
import http from "http"; import http from "http";
@@ -38,6 +46,8 @@ export interface Servers {
ids(): number[]; ids(): number[];
defaultId(): number; defaultId(): number;
onOnline(cb: (id: number) => void): void; onOnline(cb: (id: number) => void): void;
beforeDelete(cb: (id: number, force: boolean) => Promise<{ error: string; forcible: boolean } | null>): void;
afterDelete(cb: (id: number) => void): void;
} }
interface MasterRow { interface MasterRow {
@@ -69,13 +79,17 @@ const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения
const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url"); const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url");
// ── Простой лимитер по IP ────────────────────────────────────────────────── // ── Простой лимитер по IP ──────────────────────────────────────────────────
function makeLimiter(max: number, windowMs: number) { // onLimited зовётся один раз за окно — на первом отказе, чтобы лог не забивало.
function makeLimiter(max: number, windowMs: number, onLimited?: (ip: string) => void) {
const hits = new Map<string, { n: number; reset: number }>(); const hits = new Map<string, { n: number; reset: number }>();
return (ip: string): boolean => { return (ip: string): boolean => {
const now = Date.now(); const now = Date.now();
const e = hits.get(ip); const e = hits.get(ip);
if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; } if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; }
if (e.n >= max) return false; if (e.n >= max) {
if (e.n === max) { e.n++; onLimited?.(ip); }
return false;
}
e.n++; e.n++;
return true; return true;
}; };
@@ -172,6 +186,10 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
profile TEXT NOT NULL, profile TEXT NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS sub_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`); `);
// Версия клиента — необязательное поле, добавленное позже: у уже созданной // Версия клиента — необязательное поле, добавленное позже: у уже созданной
// таблицы колонки может не быть. // таблицы колонки может не быть.
@@ -185,20 +203,38 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
uidb.exec(` uidb.exec(`
INSERT OR IGNORE INTO master_servers (master_id, server_id) INSERT OR IGNORE INTO master_servers (master_id, server_id)
SELECT id, server_id FROM master_keys SELECT id, server_id FROM master_keys
WHERE id NOT IN (SELECT master_id FROM master_servers); WHERE revoked_at IS NULL AND id NOT IN (SELECT master_id FROM master_servers);
INSERT OR IGNORE INTO device_peers (device_id, server_id, peer_pub) INSERT OR IGNORE INTO device_peers (device_id, server_id, peer_pub)
SELECT d.id, m.server_id, d.pub_key FROM devices d JOIN master_keys m ON m.id = d.master_id SELECT d.id, m.server_id, d.pub_key FROM devices d JOIN master_keys m ON m.id = d.master_id
WHERE d.id NOT IN (SELECT device_id FROM device_peers); WHERE d.id NOT IN (SELECT device_id FROM device_peers);
`); `);
// Id панели — область её пиров на серверах. Живёт, пока живёт ui.db; копия в panel.id
// нужна CLI (у него нет SQLite), чтобы отличать пиров этой панели на awg-ctrl.
uidb.prepare("INSERT OR IGNORE INTO sub_meta (key, value) VALUES ('panel_id', ?)").run(crypto.randomBytes(6).toString("hex"));
const panelId = (uidb.prepare("SELECT value FROM sub_meta WHERE key = 'panel_id'").get() as { value: string }).value;
try { fs.writeFileSync(path.join(deps.baseDir, "panel.id"), panelId + "\n"); }
catch (e) { console.warn(`sub: не удалось записать panel.id: ${(e as Error).message}`); }
const q = { 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"), // И отозванные: удалённый ключ висит в списке, пока все его серверы не подтвердят удаление.
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m ORDER BY id"),
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"), masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
masterRow: uidb.prepare<[number]>("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE id = ?"),
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? 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, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"), masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"), 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 = ?"), masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE id = ?"), masterRevoke: uidb.prepare<[number, number]>("UPDATE master_keys SET revoked_at = ? WHERE id = ?"),
// Сервер сведён и надгробий на нём нет — отозванным ключам он больше не нужен.
revokedServerDone: uidb.prepare<[number]>(
"DELETE FROM master_servers WHERE server_id = ? AND master_id IN (SELECT id FROM master_keys WHERE revoked_at IS NOT NULL)"),
revokedGone: uidb.prepare("DELETE FROM master_keys WHERE revoked_at IS NOT NULL AND id NOT IN (SELECT master_id FROM master_servers)"),
liveOnServer: uidb.prepare<[number]>(
"SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NULL"),
revokedOnServer: uidb.prepare<[number]>(
"SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NOT NULL"),
serverRelease: uidb.prepare<[number]>("DELETE FROM master_servers WHERE server_id = ?"),
serversOf: uidb.prepare<[number]>("SELECT server_id FROM master_servers WHERE master_id = ? ORDER BY server_id"), serversOf: uidb.prepare<[number]>("SELECT server_id FROM master_servers WHERE master_id = ? ORDER BY server_id"),
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)"), serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)"),
serverDel: uidb.prepare<[number, number]>("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?"), serverDel: uidb.prepare<[number, number]>("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?"),
@@ -228,6 +264,16 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
tombsOf: uidb.prepare<[number]>("SELECT pub_key FROM peer_tombstones WHERE server_id = ?"), tombsOf: uidb.prepare<[number]>("SELECT pub_key FROM peer_tombstones WHERE server_id = ?"),
tombAdd: uidb.prepare<[number, string]>("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)"), tombAdd: uidb.prepare<[number, string]>("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)"),
tombDel: uidb.prepare<[number, string]>("DELETE FROM peer_tombstones WHERE server_id = ? AND pub_key = ?"), tombDel: uidb.prepare<[number, string]>("DELETE FROM peer_tombstones WHERE server_id = ? AND pub_key = ?"),
tombCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM peer_tombstones WHERE server_id = ?"),
// Что панели нужно на сервере: текущий ключ каждого устройства и ключ, который там стоит
// сейчас (расходится с текущим, пока смена ключа не догнала сервер).
keepOf: uidb.prepare<[number, number]>(`
SELECT d.pub_key AS pub FROM device_peers l JOIN devices d ON d.id = l.device_id WHERE l.server_id = ?
UNION SELECT peer_pub FROM device_peers WHERE server_id = ? AND peer_pub IS NOT NULL`),
// Связи, которые считаются сведёнными: на сервере должен стоять текущий ключ устройства.
linksSettled: uidb.prepare<[number]>(`
SELECT l.device_id, l.peer_pub FROM device_peers l JOIN devices d ON d.id = l.device_id
WHERE l.server_id = ? AND l.peer_pub = d.pub_key`),
profileGet: uidb.prepare<[number]>("SELECT profile FROM server_profiles WHERE server_id = ?"), profileGet: uidb.prepare<[number]>("SELECT profile FROM server_profiles WHERE server_id = ?"),
profileSet: uidb.prepare<[number, string, number]>( profileSet: uidb.prepare<[number, string, number]>(
"INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at"), "INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at"),
@@ -290,7 +336,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
for (const l of q.linksToFix.all(id) as (LinkRow & { dev_pub: string; master_id: number })[]) { for (const l of q.linksToFix.all(id) as (LinkRow & { dev_pub: string; master_id: number })[]) {
// null — не вышло, попробуем в следующий проход; "gone" — связь пропала, проход прекращаем. // null — не вышло, попробуем в следующий проход; "gone" — связь пропала, проход прекращаем.
const add = async (): Promise<Peer | null | "gone"> => { const add = async (): Promise<Peer | null | "gone"> => {
const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `m${l.master_id}/d${l.device_id}` }); const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `${panelId}/m${l.master_id}/d${l.device_id}` });
if (hasCtrlOk(r.status)) return r.data; if (hasCtrlOk(r.status)) return r.data;
if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся
return gone(r.status) ? "gone" : null; return gone(r.status) ? "gone" : null;
@@ -321,6 +367,33 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
} }
q.linkSet.run(l.dev_pub, peer.ip, peer.psk_key, pending, l.device_id, id); q.linkSet.run(l.dev_pub, peer.ip, peer.psk_key, pending, l.device_id, id);
} }
await syncPeers(id, ctrl);
}
// Сведение пиров панели на сервере — в конце прохода и под его блокировкой: список строится
// из ui.db сейчас, поэтому устройство, удалённое посреди прохода, в него уже не попадёт, и
// ключ, поставленный для него этим проходом, снимется здесь же.
async function syncPeers(id: number, ctrl: Ctrl) {
const keep = (q.keepOf.all(id, id) as { pub: string }[]).map(r => r.pub);
const r = await ctrl("PUT", "/api/peers/sync", { owner: panelId, keep });
if (hasCtrlOk(r.status)) {
// Самолечение: ключ считается стоящим, а его нет (откат смены ключа после потерянного
// ответа, пир сняли руками) — забываем и ставим заново следующим проходом.
const present = new Set(r.data?.present as string[]);
let lost = 0;
for (const l of q.linksSettled.all(id) as { device_id: number; peer_pub: string }[]) {
if (present.has(l.peer_pub)) continue;
q.linkSet.run(null, null, null, 0, l.device_id, id);
lost++;
}
if (lost) void reconcile(id);
} else if (r.status !== 400 && r.status !== 413) {
return; // 400/413 — awg-ctrl старше sync: чистим, как раньше, одними надгробиями
}
if ((q.tombCount.get(id) as { n: number }).n > 0) return;
q.revokedServerDone.run(id);
q.revokedGone.run();
} }
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */ /** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
@@ -387,49 +460,69 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
verify: (req, _res, buf) => { (req as any).rawBody = buf; }, verify: (req, _res, buf) => { (req as any).rawBody = buf; },
})); }));
const registerLimit = makeLimiter(30, 15 * 60 * 1000); const limited = (what: string) => (ip: string) => console.warn(`sub: 429 ${what} ip=${ip} — лимит исчерпан на 15 минут`);
const failLimit = makeLimiter(30, 15 * 60 * 1000); const registerLimit = makeLimiter(30, 15 * 60 * 1000, limited("register"));
const failLimit = makeLimiter(30, 15 * 60 * 1000, limited("неудачные запросы"));
const seenSigs = new Map<string, number>(); // подпись → когда забыть const seenSigs = new Map<string, number>(); // подпись → когда забыть
const ipOf = (req: Request) => req.socket.remoteAddress ?? "unknown";
function send(res: Response, status: number, payload: unknown) { // Ответ подписан и несёт эхо запроса: device (X-Sen-Device) и ts (X-Sen-Ts). Клиент сверяет
res.status(status).json(signResponse(payload, signKey as crypto.KeyObject)); // их со своим запросом — иначе подсунуть ему можно было бы чужой подписанный ответ (чужой
// revoked, старый config), подпись ведь на запрос не завязана.
function send(req: Request, res: Response, status: number, payload: object) {
const echo: { device?: number; ts?: number } = {};
const device = Number(req.headers["x-sen-device"]), ts = Number(req.headers["x-sen-ts"]);
if (req.headers["x-sen-device"] !== undefined && Number.isSafeInteger(device)) echo.device = device;
if (req.headers["x-sen-ts"] !== undefined && Number.isSafeInteger(ts)) echo.ts = ts;
// Поля ответа важнее эха: device из тела register не перетрёт лишний заголовок.
res.status(status).json(signResponse({ ...echo, ...payload }, signKey as crypto.KeyObject));
} }
// Проверка подписи запроса + защита от повторов. auth_pub — 32 байта. // Проверка подписи запроса + защита от повторов. auth_pub — 32 байта.
function checkSigned(req: Request, authPub: Buffer): boolean { // null — всё верно, иначе причина отказа (для лога).
function signFailure(req: Request, authPub: Buffer): string | null {
const ts = Number(req.headers["x-sen-ts"]); const ts = Number(req.headers["x-sen-ts"]);
const sig = String(req.headers["x-sen-sig"] ?? ""); const sig = String(req.headers["x-sen-sig"] ?? "");
if (!Number.isInteger(ts) || Math.abs(ts - now()) > TS_WINDOW || !sig) return false; if (!Number.isInteger(ts) || !sig) return "no_signature";
if (Math.abs(ts - now()) > TS_WINDOW) return `ts_window (часы клиента расходятся на ${ts - now()} с)`;
const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0); const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0);
if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return false; if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return "bad_signature";
const t = Date.now(); const t = Date.now();
for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k); for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k);
if (seenSigs.has(sig)) return false; if (seenSigs.has(sig)) return "replay";
seenSigs.set(sig, t + 2 * TS_WINDOW * 1000); seenSigs.set(sig, t + 2 * TS_WINDOW * 1000);
return true; return null;
} }
interface DevReq extends Request { device?: DeviceRow } interface DevReq extends Request { device?: DeviceRow }
function deviceAuth(req: Request, res: Response, next: NextFunction) { function deviceAuth(req: Request, res: Response, next: NextFunction) {
const id = Number(req.headers["x-sen-device"]); const id = Number(req.headers["x-sen-device"]);
const dev = Number.isInteger(id) ? q.deviceById.get(id) as DeviceRow | undefined : undefined; const dev = Number.isSafeInteger(id) && id > 0 ? q.deviceById.get(id) as DeviceRow | undefined : undefined;
if (dev && checkSigned(req, Buffer.from(dev.auth_pub, "base64url"))) { // Устройства нет — его удалили (id не переиспользуются: AUTOINCREMENT). Отвечаем 410,
// чтобы клиент снял конфиг, и не считаем в лимит неудач: такие клиенты опрашивают
// по таймеру и иначе выбивали бы лимит соседям за тем же NAT. Существующему
// устройству 410 не выдать, поэтому подписанный ответ тут можно отдать без проверки.
if (!dev && Number.isSafeInteger(id) && id > 0) { send(req, res, 410, { error: "revoked" }); return; }
const why = dev ? signFailure(req, Buffer.from(dev.auth_pub, "base64url")) : "no_device";
if (!why) {
(req as DevReq).device = dev; (req as DevReq).device = dev;
next(); next();
return; return;
} }
// Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает. // Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает.
if (!failLimit(req.socket.remoteAddress ?? "unknown")) { send(res, 429, { error: "rate_limited" }); return; } const ip = ipOf(req);
send(res, 401, { error: "unauthorized" }); if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
console.warn(`sub: 401 ${req.method} ${req.path} device=${req.headers["x-sen-device"] ?? "-"} ${why} ip=${ip}`);
send(req, res, 401, { error: "unauthorized" });
} }
const wrap = (fn: (req: Request, res: Response) => Promise<void>) => const wrap = (fn: (req: Request, res: Response) => Promise<void>) =>
(req: Request, res: Response) => { fn(req, res).catch(() => { send(res, 502, { error: "unavailable" }); }); }; (req: Request, res: Response) => { fn(req, res).catch(() => { send(req, res, 502, { error: "unavailable" }); }); };
sub.post("/sub/v1/register", wrap(async (req, res) => { sub.post("/sub/v1/register", wrap(async (req, res) => {
const ip = req.socket.remoteAddress ?? "unknown"; const ip = ipOf(req);
if (!registerLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; } if (!registerLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
const b = (req.body ?? {}) as Record<string, unknown>; const b = (req.body ?? {}) as Record<string, unknown>;
const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null; const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null;
@@ -440,16 +533,21 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : ""; 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 platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : "";
const version = str(b.version, CLIENT_VERSION) ?? ""; const version = str(b.version, CLIENT_VERSION) ?? "";
if (!secretB || !deviceId || !pubKey || !authPubS) { send(res, 400, { error: "bad_request" }); return; } if (!secretB || !deviceId || !pubKey || !authPubS) { send(req, res, 400, { error: "bad_request" }); return; }
// Регистрация подписана ключом auth_pub из самого тела — доказательство владения. // Регистрация подписана ключом auth_pub из самого тела — доказательство владения.
const authPub = Buffer.from(authPubS, "base64url"); const authPub = Buffer.from(authPubS, "base64url");
if (authPub.length !== 32 || !checkSigned(req, authPub)) { send(res, 401, { error: "unauthorized" }); return; } const why = authPub.length !== 32 ? "bad_auth_pub" : signFailure(req, authPub);
if (why) {
console.warn(`sub: 401 register ${why} ip=${ip}`);
send(req, res, 401, { error: "unauthorized" });
return;
}
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined; const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
if (!master) { send(res, 404, { error: "not_found" }); return; } if (!master) { send(req, res, 404, { error: "not_found" }); return; }
const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id)); const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id));
if (serverIds.length === 0) { send(res, 502, { error: "unavailable" }); return; } if (serverIds.length === 0) { send(req, res, 502, { error: "unavailable" }); return; }
let deviceRowId = 0; let deviceRowId = 0;
let touched: number[] = []; let touched: number[] = [];
@@ -463,8 +561,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
for (const sid of serverIds) q.linkAdd.run(deviceRowId, sid); for (const sid of serverIds) q.linkAdd.run(deviceRowId, sid);
})(); })();
} catch (e: any) { } catch (e: any) {
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; } if (e?.code === "limit") { send(req, res, 403, { error: "device_limit" }); return; }
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты send(req, res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
} }
await reconcileAll([...touched, ...serverIds]); await reconcileAll([...touched, ...serverIds]);
@@ -473,12 +571,12 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
if (cfg.servers.length === 0) { if (cfg.servers.length === 0) {
// Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже. // Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже.
await reconcileAll(forgetDevice(dev)); await reconcileAll(forgetDevice(dev));
send(res, 502, { error: "unavailable" }); send(req, res, 502, { error: "unavailable" });
return; return;
} }
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу, // devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
// без отдельного запроса списка. // без отдельного запроса списка.
send(res, 201, { send(req, res, 201, {
device: dev.id, config: cfg, device: dev.id, config: cfg,
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
}); });
@@ -488,15 +586,15 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register, // регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register,
// а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать. // а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать.
sub.post("/sub/v1/peek", wrap(async (req, res) => { sub.post("/sub/v1/peek", wrap(async (req, res) => {
const ip = req.socket.remoteAddress ?? "unknown"; const ip = ipOf(req);
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 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; const master = secretB ? q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined : undefined;
if (!master) { if (!master) {
if (!failLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; } if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
send(res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" }); send(req, res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
return; return;
} }
send(res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit }); send(req, 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) => { sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
@@ -505,7 +603,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели. // Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
const v = String(req.headers["x-sen-version"] ?? ""); const v = String(req.headers["x-sen-version"] ?? "");
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id); if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
send(res, 200, { config: await readyConfig(dev, req) }); send(req, res, 200, { config: await readyConfig(dev, req) });
})); }));
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать // Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
@@ -513,8 +611,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => { sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow; const dev = (req as DevReq).device as DeviceRow;
const master = q.masterById.get(dev.master_id) as MasterRow | undefined; const master = q.masterById.get(dev.master_id) as MasterRow | undefined;
if (!master) { send(res, 404, { error: "not_found" }); return; } if (!master) { send(req, res, 404, { error: "not_found" }); return; }
send(res, 200, { send(req, res, 200, {
name: master.label, name: master.label,
device_limit: master.device_limit, device_limit: master.device_limit,
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({ devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
@@ -529,10 +627,10 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => { sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow; const dev = (req as DevReq).device as DeviceRow;
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key; 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; } if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(req, res, 400, { error: "bad_request" }); return; }
if (pub === dev.pub_key) { send(res, 200, { config: await readyConfig(dev, req) }); return; } if (pub === dev.pub_key) { send(req, res, 200, { config: await readyConfig(dev, req) }); return; }
try { q.devicePub.run(pub, 0, dev.id); } try { q.devicePub.run(pub, 0, dev.id); }
catch { send(res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства catch { send(req, res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id); const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id);
await reconcileAll(ids); await reconcileAll(ids);
@@ -540,79 +638,112 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
if (cfg.servers.length === 0) { if (cfg.servers.length === 0) {
q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id); q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id);
await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить
send(res, 502, { error: "unavailable" }); send(req, res, 502, { error: "unavailable" });
return; return;
} }
send(res, 200, { config: cfg }); send(req, res, 200, { config: cfg });
})); }));
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => { sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow; const dev = (req as DevReq).device as DeviceRow;
await reconcileAll(forgetDevice(dev)); await reconcileAll(forgetDevice(dev));
send(res, 200, { ok: true }); send(req, res, 200, { ok: true });
})); }));
sub.use((_req, res) => { send(res, 404, { error: "not_found" }); }); sub.use((req, res) => { send(req, res, 404, { error: "not_found" }); });
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять. // Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
sub.use((_err: unknown, _req: Request, res: Response, _next: NextFunction) => { send(res, 400, { error: "bad_request" }); }); sub.use((_err: unknown, req: Request, res: Response, _next: NextFunction) => { send(req, res, 400, { error: "bad_request" }); });
// ── Админ-роуты панели (монтируются в server.ts за JWT) ───────────────── // ── Мастер-ключи: общая логика панели и внешнего API ───────────────────
const aw = (fn: (req: Request, res: Response) => Promise<void>) => // Панель (/ui/masterkeys, /ui/devices, за JWT) видит всё. Внешний API (/api/v1/masterkeys,
(req: Request, res: Response) => { fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); }); }; // ключ awgk_) — только ключи, чьи серверы целиком входят в набор серверов API-ключа:
const idOf = (req: Request) => Number(req.params.id); // иначе он мог бы удалить или перенастроить раздачу на сервере, которого не видит.
// Scope: null — панель, иначе набор серверов API-ключа.
type Scope = number[] | null;
class ApiError extends Error {
constructor(public status: number, message: string) { super(message); }
}
const fail = (status: number, message: string): never => { throw new ApiError(status, message); };
// ApiError — её статус; остальное (awg-ctrl не ответил посреди действия) — 502.
const aw = (fn: (req: Request, res: Response) => unknown) => (req: Request, res: Response) => {
Promise.resolve().then(() => fn(req, res)).catch(e => {
if (e instanceof ApiError) res.status(e.status).json({ error: e.message });
else res.status(502).json({ error: "awg-ctrl недоступен" });
});
};
const idOf = (v: string) => { const n = Number(v); return Number.isSafeInteger(n) ? n : -1; }; // -1 — не найдётся
const LABEL = /^[^\r\n<>]{1,40}$/; 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 limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
// Набор серверов из тела запроса: непустой, без повторов, только существующие. const visible = (masterId: number, scope: Scope) =>
function parseServers(v: unknown): number[] | string { scope === null || serverIdsOf(masterId).every(id => scope.includes(id));
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
if (v.length > MAX_SERVERS) return `Не больше ${MAX_SERVERS} серверов`; // Живой (не удаляемый) ключ в пределах области; чужой — как несуществующий.
const ids = [...new Set(v)]; function masterFor(id: number, scope: Scope): MasterRow {
if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) return "Такого сервера нет"; const m = q.masterById.get(id) as MasterRow | undefined;
return m && visible(m.id, scope) ? m : fail(404, "Не найден");
}
// Устройство — только вместе со своим мастер-ключом (у внешнего API пути вложенные).
function deviceFor(id: number, master?: MasterRow): DeviceRow {
const d = q.deviceById.get(id) as DeviceRow | undefined;
return d && (!master || d.master_id === master.id) ? d : fail(404, "Не найдено");
}
// Набор серверов из тела запроса: непустой, без повторов, только существующие и — для
// внешнего API — только из области его ключа.
function parseServers(v: unknown, scope: Scope): number[] {
if (!Array.isArray(v) || v.length === 0) fail(400, "Нужен хотя бы один сервер");
const list = v as unknown[];
if (list.length > MAX_SERVERS) fail(400, `Не больше ${MAX_SERVERS} серверов`);
const ids = [...new Set(list)];
if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) fail(400, "Такого сервера нет");
if (scope && !ids.every(id => scope.includes(id as number))) fail(403, "Сервер вне области API-ключа");
return (ids as number[]).sort((a, b) => a - b); return (ids as number[]).sort((a, b) => a - b);
} }
const masterKeys = Router(); // deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
function masterOut(m: MasterRow & { devices: number }) {
masterKeys.get("/", (_req, res) => { return {
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, id: m.id, label: m.label, device_limit: m.device_limit,
devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at, devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at,
})), ...(m.revoked_at !== null ? { deleting: serverIdsOf(m.id) } : {}),
}); };
}); }
const masterOutById = (id: number) => masterOut(q.masterRow.get(id) as MasterRow & { devices: number });
masterKeys.post("/", (req, res) => { const listMasters = (scope: Scope) =>
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown }; (q.masters.all() as (MasterRow & { devices: number })[]).filter(m => visible(m.id, scope)).map(masterOut);
if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; }
// Серверы по умолчанию: у панели — сервер по умолчанию, у API-ключа — вся его область.
function createMaster(body: unknown, scope: Scope) {
const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown };
if (typeof label !== "string" || !LABEL.test(label)) fail(400, "Метка: до 40 символов, без переводов строк и <>");
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit; const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; } if (!limitOk(limit)) fail(400, "Лимит устройств: от 1 до 100");
const ids = parseServers(wanted === undefined ? [servers.defaultId()] : wanted); let ids: number[];
if (typeof ids === "string") { res.status(400).json({ error: wanted === undefined ? "Сначала добавьте сервер" : ids }); return; } try { ids = parseServers(wanted ?? scope ?? [servers.defaultId()], scope); }
catch (e) { throw wanted === undefined && scope === null ? new ApiError(400, "Сначала добавьте сервер") : e; }
let id = 0; let id = 0;
uidb.transaction(() => { uidb.transaction(() => {
id = Number(q.masterInsert.run(label, crypto.randomBytes(16), limit, ids[0], now()).lastInsertRowid); id = Number(q.masterInsert.run(label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
for (const sid of ids) q.serverAdd.run(id, sid); for (const sid of ids) q.serverAdd.run(id, sid);
})(); })();
res.status(201).json({ id, label, device_limit: limit, devices: 0, servers: ids }); return masterOutById(id);
}); }
masterKeys.patch("/:id", aw(async (req, res) => { async function updateMaster(m: MasterRow, body: unknown, scope: Scope) {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined; const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown };
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown };
const nl = label ?? m.label, nd = device_limit ?? m.device_limit; 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 (typeof nl !== "string" || !LABEL.test(nl)) fail(400, "Неверная метка");
if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; } if (!limitOk(nd)) fail(400, "Лимит устройств: от 1 до 100");
let ids = serverIdsOf(m.id); let ids = serverIdsOf(m.id);
const affected: number[] = []; const affected: number[] = [];
if (wanted !== undefined) { if (wanted !== undefined) {
const next = parseServers(wanted); const next = parseServers(wanted, scope);
if (typeof next === "string") { res.status(400).json({ error: next }); return; }
const added = next.filter(s => !ids.includes(s)), removed = ids.filter(s => !next.includes(s)); const added = next.filter(s => !ids.includes(s)), removed = ids.filter(s => !next.includes(s));
const devices = q.devicesOf.all(m.id) as DeviceRow[]; const devices = q.devicesOf.all(m.id) as DeviceRow[];
uidb.transaction(() => { uidb.transaction(() => {
@@ -632,54 +763,49 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
affected.push(...added, ...removed); affected.push(...added, ...removed);
ids = next; ids = next;
} }
q.masterUpdate.run(nl, nd, m.id); q.masterUpdate.run(nl as string, nd as number, m.id);
await reconcileAll(affected); await reconcileAll(affected);
res.json({ id: m.id, label: nl, device_limit: nd, servers: ids }); return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids };
})); }
masterKeys.post("/:id/rotate", (req, res) => { // Новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
const m = q.masterById.get(idOf(req)) as MasterRow | undefined; const rotateMaster = (m: MasterRow) => { q.masterSecret.run(crypto.randomBytes(16), m.id); };
if (!m) { res.status(404).json({ error: "Не найден" }); return; } const rekeyMaster = (m: MasterRow) => { q.deviceFlag.run(-1, m.id); };
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); async function deleteMaster(m: MasterRow) {
res.json({ id: m.id }); const affected = serverIdsOf(m.id);
}); uidb.transaction(() => {
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; }
const affected: number[] = [];
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d)); for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
q.masterDelete.run(m.id); // master_servers уйдут каскадом q.masterRevoke.run(now(), m.id);
})();
await reconcileAll(affected); await reconcileAll(affected);
res.json({ success: true, id: m.id }); return { success: true, id: m.id, pending: serverIdsOf(m.id) };
})); }
masterKeys.get("/:id/link", (req, res) => { // Выдать ссылку можно, только если подписка настроена и известен адрес панели.
const m = q.masterById.get(idOf(req)) as MasterRow | undefined; function linkProblem(req: Request): string | null {
if (!m) { res.status(404).json({ error: "Не найден" }); return; } if (!signKey || !signPub || !SUB_PORT) return "Подписка не настроена на сервере";
if (!signKey || !signPub || !SUB_PORT) { res.status(503).json({ error: "Подписка не настроена на сервере" }); return; } if (!subHost(req)) return "Не удалось определить адрес панели — задайте SUB_HOST";
const host = subHost(req); return null;
if (!host) { res.status(503).json({ error: "Не удалось определить адрес панели — задайте SUB_HOST" }); return; } }
function linkOf(m: MasterRow, req: Request) {
const problem = linkProblem(req);
if (problem) fail(503, problem);
const link = encodeSenLink({ const link = encodeSenLink({
tls: tlsPin !== null, tls: tlsPin !== null,
addrs: [{ host, port: SUB_PORT }], addrs: [{ host: subHost(req), port: SUB_PORT }],
secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label, secret: m.secret, signPub: signPub as Buffer, tlsPin: tlsPin ?? undefined, name: m.label,
});
res.json({ link, tls: tlsPin !== null });
}); });
return { link, tls: tlsPin !== null };
}
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть // Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ. // на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
masterKeys.get("/:id/devices", aw(async (req, res) => { async function masterDevices(m: MasterRow) {
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 stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => { await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
const r = await servers.ctrlFor(id)("GET", "/api/peers"); const r = await servers.ctrlFor(id)("GET", "/api/peers");
@@ -692,8 +818,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
}); });
} }
})); }));
res.json({ return (q.devicesOf.all(m.id) as DeviceRow[]).map(d => {
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => {
const links = q.linksOf.all(d.id) as LinkRow[]; const links = q.linksOf.all(d.id) as LinkRow[];
const s = stats.get(d.pub_key); const s = stats.get(d.pub_key);
return { return {
@@ -702,39 +827,100 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0, online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0,
servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length, servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length,
}; };
}),
}); });
})); }
const devices = Router(); async function deleteDevice(d: DeviceRow) {
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 reconcileAll(forgetDevice(d)); await reconcileAll(forgetDevice(d));
res.json({ success: true, id: d.id }); return { success: true, id: d.id };
})); }
const rekeyDevice = (d: DeviceRow) => { q.deviceFlag.run(d.id, -1); };
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 });
});
// Новый PSK на каждом сервере; недоступные получат его, когда появятся. // Новый PSK на каждом сервере; недоступные получат его, когда появятся.
devices.post("/:id/psk", aw(async (req, res) => { async function rotatePsk(d: DeviceRow) {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
q.linkPskDue.run(d.id); q.linkPskDue.run(d.id);
await reconcileAll((q.linksOf.all(d.id) as LinkRow[]).map(l => l.server_id)); await reconcileAll((q.linksOf.all(d.id) as LinkRow[]).map(l => l.server_id));
const pending = (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length; return { id: d.id, pending: (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length };
res.json({ id: d.id, pending }); }
// ── Роуты панели (монтируются в server.ts за JWT) ──────────────────────
const masterKeys = Router();
masterKeys.get("/", (_req, res) => {
res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) });
});
masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); }));
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(idOf(req.params.id), null), req.body, null)); }));
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(idOf(req.params.id), null))); }));
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(idOf(req.params.id), null), req)); }));
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(idOf(req.params.id), null)) }); }));
const devices = Router();
devices.delete("/:id", aw(async (req, res) => { res.json(await deleteDevice(deviceFor(idOf(req.params.id)))); }));
devices.post("/:id/rekey", aw((req, res) => { const d = deviceFor(idOf(req.params.id)); rekeyDevice(d); res.json({ id: d.id }); }));
devices.post("/:id/psk", aw(async (req, res) => { res.json(await rotatePsk(deviceFor(idOf(req.params.id)))); }));
// ── Внешний API: /api/v1/masterkeys (ключ awgk_, монтируется в server.ts) ─
// scopeOf — набор серверов API-ключа запроса. Устройства — вложенными путями, чтобы
// область проверялась через их мастер-ключ.
function apiRouter(scopeOf: (req: Request) => number[]) {
const r = Router();
const master = (req: Request) => masterFor(idOf(req.params.id), scopeOf(req));
const device = (req: Request) => deviceFor(idOf(req.params.device), master(req));
r.get("/", aw((req, res) => { res.json({ keys: listMasters(scopeOf(req)) }); }));
r.post("/", aw((req, res) => {
const problem = linkProblem(req); // без ссылки ключ внешней программе бесполезен
if (problem) fail(503, problem);
const out = createMaster(req.body, scopeOf(req));
res.status(201).json({ ...out, ...linkOf(masterFor(out.id, null), req) });
})); }));
r.get("/:id", aw(async (req, res) => {
const m = master(req);
// devices — счётчик, как в списке; сами устройства — в device_list.
res.json({ ...masterOutById(m.id), ...(linkProblem(req) ? { link: null, tls: tlsPin !== null } : linkOf(m, req)), device_list: await masterDevices(m) });
}));
r.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(master(req), req.body, scopeOf(req))); }));
r.post("/:id/rotate", aw((req, res) => {
const m = master(req);
rotateMaster(m);
res.json({ id: m.id, ...linkOf(masterFor(m.id, null), req) });
}));
r.post("/:id/rekey", aw((req, res) => { const m = master(req); rekeyMaster(m); res.json({ id: m.id }); }));
r.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(master(req))); }));
r.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(master(req)) }); }));
r.delete("/:id/devices/:device", aw(async (req, res) => { res.json(await deleteDevice(device(req))); }));
r.post("/:id/devices/:device/rekey", aw((req, res) => { const d = device(req); rekeyDevice(d); res.json({ id: d.id }); }));
r.post("/:id/devices/:device/psk", aw(async (req, res) => { res.json(await rotatePsk(device(req))); }));
return r;
}
// ── Листенер и фоновое сведение ──────────────────────────────────────── // ── Листенер и фоновое сведение ────────────────────────────────────────
servers.onOnline(id => { void reconcile(id); }); servers.onOnline(id => { void reconcile(id); });
// Сервер удаляют из реестра: пока им пользуется живой мастер-ключ — нельзя. Иначе сперва
// сводим его (ключи его не используют, значит, список пуст и пиры панели снимутся) — после
// удаления до него уже не дотянуться. Не вышло (нода не в сети) — удалять только с force;
// тогда отозванные ключи перестают его ждать уже после удаления (afterDelete).
servers.beforeDelete(async (id, force) => {
const live = (q.liveOnServer.all(id) as { label: string }[]).map(r => r.label);
if (live.length) return { error: `Сервер в мастер-ключах: ${live.join(", ")} — сначала уберите его оттуда`, forcible: false };
if (servers.isOnline(id)) await reconcile(id);
const waiting = (q.revokedOnServer.all(id) as { label: string }[]).map(r => r.label);
const tombs = (q.tombCount.get(id) as { n: number }).n;
if (!waiting.length && !tombs || force) return null;
return {
error: "Сервер не подтвердил, что снял пиров" + (waiting.length ? ` удалённых мастер-ключей (${waiting.join(", ")})` : "") +
". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)",
forcible: true,
};
});
servers.afterDelete(id => {
q.serverRelease.run(id);
q.revokedGone.run();
});
function start() { function start() {
// Сводим и без подписки: у ключей могут остаться хвосты на удаление. // Сводим и без подписки: у ключей могут остаться хвосты на удаление.
setInterval(() => { setInterval(() => {
@@ -749,5 +935,5 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
}); });
} }
return { masterKeys, devices, start }; return { masterKeys, devices, apiRouter, start };
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.3.2", "version": "0.3.3",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+140 -1
View File
@@ -383,12 +383,150 @@ async function joinCore(link?: string, flag?: string) {
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v); for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
writeFileSync(envFile, content, { mode: 0o600 }); writeFileSync(envFile, content, { mode: 0o600 });
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
const dropPeers = !keepPanel && process.env.UI_PASS ? await askDropPanelPeers() : false;
if (!keepPanel) await stop("ui"); if (!keepPanel) await stop("ui");
if (dropPeers) await dropArea(localPanelId()!);
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` + console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : "")); (keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
restartFresh(); 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() { async function unjoinCore() {
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; } if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
await stop("agent"); await stop("agent");
@@ -502,7 +640,7 @@ async function main() {
return; return;
} }
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin>"; const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]>";
const [,,, sub, arg] = process.argv; const [,,, sub, arg] = process.argv;
@@ -518,6 +656,7 @@ async function main() {
break; break;
case "join": await joinCore(sub, arg); break; case "join": await joinCore(sub, arg); break;
case "unjoin": await unjoinCore(); break; case "unjoin": await unjoinCore(); break;
case "peers": await peersCmd(sub, arg); break;
default: console.log(USAGE); process.exit(1); default: console.log(USAGE); process.exit(1);
} }
} }
+1 -1
View File
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места. # При бампе версии правь оба места.
VERSION="0.3.2" VERSION="0.3.3"
CHANNEL="${CHANNEL:-Beta}" CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт — задаётся окружением, в коде # Откуда брать архив и как называется продукт — задаётся окружением, в коде