Compare commits
7
Commits
v0.3.2
..
7fbff82e76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbff82e76 | ||
|
|
565b2759ed | ||
|
|
f0f2f9848a | ||
|
|
5c3bf21918 | ||
|
|
eb0ec2878c | ||
|
|
a85f5d87b2 | ||
|
|
c5e1dc9b32 |
@@ -11,4 +11,4 @@
|
|||||||
|
|
||||||
BRAND=Forgetting
|
BRAND=Forgetting
|
||||||
CHANNEL=Beta
|
CHANNEL=Beta
|
||||||
VERSION=0.3.2
|
VERSION=0.3.4
|
||||||
|
|||||||
@@ -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*
|
||||||
|
|||||||
@@ -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, uuid, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) |
|
||||||
|
| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`. `uuid` — постоянный идентификатор ключа: не меняется ни при перевыпуске ссылки, ни при правке метки. Везде, где в пути `:id`, можно передать и `uuid` |
|
||||||
|
| `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.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||||
| `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, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`; `:id` в путях — id или `uuid` |
|
||||||
|
| `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
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnez
|
|||||||
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
|
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
|
||||||
let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения
|
let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения
|
||||||
|
|
||||||
|
// Версия Forgetting на этой ноде — панель показывает её в карточке сервера. CLI передаёт её из
|
||||||
|
// .env в корне проекта; при запуске вручную читаем тот же .env сами.
|
||||||
|
function envFromFile(file: string, key: string): string {
|
||||||
|
try {
|
||||||
|
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
|
||||||
|
return m ? m[1].trim() : "";
|
||||||
|
} catch { return ""; }
|
||||||
|
}
|
||||||
|
const VERSION = process.env.VERSION || envFromFile(path.join(__dirname, "..", ".env"), "VERSION");
|
||||||
|
|
||||||
if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) {
|
if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) {
|
||||||
console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`");
|
console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -138,6 +148,7 @@ function connectOnce(): Promise<{ authFailed: boolean }> {
|
|||||||
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
|
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
|
||||||
};
|
};
|
||||||
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
|
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
|
||||||
|
if (VERSION) auth.version = VERSION;
|
||||||
ws.send(JSON.stringify(auth));
|
ws.send(JSON.stringify(auth));
|
||||||
} else if (m.t === "ready") {
|
} else if (m.t === "ready") {
|
||||||
ready = true; backoff = 1000; armDead();
|
ready = true; backoff = 1000; armDead();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-agent",
|
"name": "awg-agent",
|
||||||
"version": "0.3.2",
|
"version": "0.3.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+54
-1
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-control",
|
"name": "awg-control",
|
||||||
"version": "0.3.2",
|
"version": "0.3.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+45
-19
@@ -15,6 +15,7 @@ const now = () => Math.floor(Date.now() / 1000);
|
|||||||
const rnd = (n: number) => Math.floor(Math.random() * n);
|
const rnd = (n: number) => Math.floor(Math.random() * n);
|
||||||
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
|
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
|
||||||
const GEN = "3.1";
|
const GEN = "3.1";
|
||||||
|
const MOCK_VERSION = "0.3.3";
|
||||||
|
|
||||||
// ── пользователи (vpn://) ──
|
// ── пользователи (vpn://) ──
|
||||||
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
|
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
|
||||||
@@ -34,31 +35,32 @@ const usersOf: Record<number, U[]> = {
|
|||||||
};
|
};
|
||||||
const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; };
|
const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; };
|
||||||
const U_ = (q: express.Request) => usersOf[srv(q)];
|
const U_ = (q: express.Request) => usersOf[srv(q)];
|
||||||
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean }
|
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null }
|
||||||
const mnodes: MNode[] = [
|
const mnodes: MNode[] = [
|
||||||
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false },
|
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.3.2" }, // отстаёт от панели
|
||||||
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true },
|
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null },
|
||||||
|
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: true, pending: false, version: null }, // старый агент: версию не шлёт
|
||||||
];
|
];
|
||||||
let nodeId = 2;
|
let nodeId = 3;
|
||||||
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; uuid: string; 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++, uuid: crypto.randomUUID(), 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, uuid: m.uuid, 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: MOCK_VERSION }));
|
||||||
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: "Неверный логин или пароль" }));
|
||||||
app.post("/logout", (_q, r) => r.json({ ok: true }));
|
app.post("/logout", (_q, r) => r.json({ ok: true }));
|
||||||
app.get("/health", (q, r) => {
|
app.get("/health", (q, r) => {
|
||||||
@@ -68,19 +70,28 @@ app.get("/health", (q, r) => {
|
|||||||
|
|
||||||
// ── серверы ──
|
// ── серверы ──
|
||||||
const nodeView = () => [
|
const nodeView = () => [
|
||||||
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
|
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, version: MOCK_VERSION, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
|
||||||
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null,
|
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, version: n.version,
|
||||||
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
|
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
|
||||||
];
|
];
|
||||||
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() }));
|
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
|
||||||
app.post("/ui/nodes", (q, r) => {
|
app.post("/ui/nodes", (q, r) => {
|
||||||
const name = String(q.body.name ?? "");
|
const name = String(q.body.name ?? "");
|
||||||
if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" });
|
if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" });
|
||||||
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true });
|
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true, version: null });
|
||||||
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,24 +110,39 @@ 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) }));
|
||||||
app.post("/ui/masterkeys", (q, r) => {
|
app.post("/ui/masterkeys", (q, r) => {
|
||||||
const limit = q.body.device_limit ?? 3;
|
const limit = q.body.device_limit ?? 3;
|
||||||
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
|
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
|
||||||
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
|
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
|
||||||
masters.push(m); r.status(201).json(mkOut(m));
|
masters.push(m); r.status(201).json(mkOut(m));
|
||||||
});
|
});
|
||||||
app.patch("/ui/masterkeys/:id", (q, r) => {
|
app.patch("/ui/masterkeys/:id", (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: "Не найден" });
|
||||||
if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); }
|
if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); }
|
||||||
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m));
|
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit;
|
||||||
|
// Как sub.ts: лишние устройства сверх лимита — отвязать, начиная с подключившихся последними.
|
||||||
|
const unbound = [...m.devs].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, Math.max(0, m.devs.length - m.device_limit)).map(d => d.id);
|
||||||
|
m.devs = m.devs.filter(d => !unbound.includes(d.id));
|
||||||
|
r.json({ ...mkOut(m), unbound });
|
||||||
});
|
});
|
||||||
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 });
|
||||||
|
|||||||
+35
-15
@@ -25,7 +25,7 @@ export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<
|
|||||||
interface NodeRow {
|
interface NodeRow {
|
||||||
id: number; name: string; pub: string | null;
|
id: number; name: string; pub: string | null;
|
||||||
join_hash: Buffer | null; join_expires: number | null;
|
join_hash: Buffer | null; join_expires: number | null;
|
||||||
created_at: number; last_seen: number | null;
|
created_at: number; last_seen: number | null; version: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Health {
|
interface Health {
|
||||||
@@ -46,11 +46,12 @@ const RPC_TIMEOUT = 15_000;
|
|||||||
const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго
|
const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго
|
||||||
const NAME_RE = /^[^\r\n<>]{1,40}$/;
|
const NAME_RE = /^[^\r\n<>]{1,40}$/;
|
||||||
const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/;
|
const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/;
|
||||||
|
const VERSION_RE = /^[\w.+-]{1,32}$/;
|
||||||
|
|
||||||
// Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent.
|
// Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent.
|
||||||
const authMsg = (nonce: string, nodeId: number) => Buffer.from(`awg-node-v1\n${nonce}\n${nodeId}`);
|
const authMsg = (nonce: string, nodeId: number) => Buffer.from(`awg-node-v1\n${nonce}\n${nodeId}`);
|
||||||
|
|
||||||
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string }) {
|
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string; version: string }) {
|
||||||
const { uidb, ctrlLocal } = deps;
|
const { uidb, ctrlLocal } = deps;
|
||||||
|
|
||||||
const NODE_PORT = Number(process.env.NODE_PORT) || 0;
|
const NODE_PORT = Number(process.env.NODE_PORT) || 0;
|
||||||
@@ -94,6 +95,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
last_seen INTEGER
|
last_seen INTEGER
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают). Колонка
|
||||||
|
// добавлена позже, у существующей таблицы её может не быть.
|
||||||
|
if (!(uidb.prepare("PRAGMA table_info(nodes)").all() as { name: string }[]).some(c => c.name === "version"))
|
||||||
|
uidb.exec("ALTER TABLE nodes ADD COLUMN version TEXT");
|
||||||
const q = {
|
const q = {
|
||||||
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
|
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
|
||||||
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
|
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
|
||||||
@@ -101,7 +106,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"),
|
rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"),
|
||||||
rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"),
|
rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"),
|
||||||
bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"),
|
bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"),
|
||||||
seen: uidb.prepare<[number, number]>("UPDATE nodes SET last_seen = ? WHERE id = ?"),
|
seen: uidb.prepare<[number, string | null, number]>("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?"),
|
||||||
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
|
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
|
||||||
};
|
};
|
||||||
const now = () => Math.floor(Date.now() / 1000);
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
@@ -148,6 +153,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;
|
||||||
@@ -215,7 +230,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
|
|
||||||
clearTimeout(authTimer);
|
clearTimeout(authTimer);
|
||||||
if (joining) q.bind.run(pubB64 as string, row.id);
|
if (joining) q.bind.run(pubB64 as string, row.id);
|
||||||
q.seen.run(now(), row.id);
|
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
|
||||||
|
const version = typeof m.version === "string" && VERSION_RE.test(m.version) ? m.version : null;
|
||||||
|
q.seen.run(now(), version, row.id);
|
||||||
|
|
||||||
const old = conns.get(row.id);
|
const old = conns.get(row.id);
|
||||||
conn = { ws, seq: 0, alive: true, pending: new Map() };
|
conn = { ws, seq: 0, alive: true, pending: new Map() };
|
||||||
@@ -293,15 +310,15 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
const nodes: unknown[] = [];
|
const nodes: unknown[] = [];
|
||||||
if (LOCAL) {
|
if (LOCAL) {
|
||||||
const h = health.get(0) ?? null;
|
const h = health.get(0) ?? null;
|
||||||
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, health: h });
|
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
|
||||||
}
|
}
|
||||||
for (const n of q.all.all() as NodeRow[]) {
|
for (const n of q.all.all() as NodeRow[]) {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: n.id, name: n.name, local: false, online: conns.has(n.id),
|
id: n.id, name: n.name, local: false, online: conns.has(n.id),
|
||||||
pending: n.pub === null, last_seen: n.last_seen, health: health.get(n.id) ?? null,
|
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.json({ hub: hubReady(), local: LOCAL, nodes });
|
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/", (req: Request, res: Response) => {
|
router.post("/", (req: Request, res: Response) => {
|
||||||
@@ -339,16 +356,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 +375,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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-ui",
|
"name": "awg-ui",
|
||||||
"version": "0.3.2",
|
"version": "0.3.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+97
-12
@@ -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 не
|
||||||
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
|
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
|
||||||
@@ -286,13 +355,29 @@ app.use("/api/v1", ext);
|
|||||||
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
|
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
|
||||||
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
|
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
|
||||||
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
|
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
|
||||||
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
|
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname, version: VERSION });
|
||||||
app.use("/ui/nodes", requireAuth, nodes.router);
|
app.use("/ui/nodes", requireAuth, nodes.router);
|
||||||
|
|
||||||
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
|
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ html, body, #root { height: 100%; }
|
|||||||
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
|
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
|
||||||
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
|
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
|
||||||
--warn-dot: #e08a2c;
|
--warn-dot: #e08a2c;
|
||||||
|
--tertiary-container: #ffdcad; /* design.md: tertiary-container */
|
||||||
|
--on-tertiary-container: #7a4a14;
|
||||||
|
|
||||||
--danger-border: #e6c4bc;
|
--danger-border: #e6c4bc;
|
||||||
--danger-text: #b34a3a;
|
--danger-text: #b34a3a;
|
||||||
@@ -67,6 +69,8 @@ html, body, #root { height: 100%; }
|
|||||||
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
|
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
|
||||||
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
|
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
|
||||||
--warn-dot: #ffb76b;
|
--warn-dot: #ffb76b;
|
||||||
|
--tertiary-container: #4a3218; /* тёплый тёмный, как у статусов в тёмной теме */
|
||||||
|
--on-tertiary-container: #ffdcad;
|
||||||
|
|
||||||
--danger-border: #6e4540;
|
--danger-border: #6e4540;
|
||||||
--danger-text: #ffb4ab;
|
--danger-text: #ffb4ab;
|
||||||
@@ -449,6 +453,16 @@ body {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
|
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
|
||||||
|
.server-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
||||||
|
.server-chips .server-gen-wrap, .server-chips .server-gen-chip { margin-top: 0; }
|
||||||
|
.server-ver-chip {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--neutral-bg);
|
||||||
|
color: var(--neutral-text);
|
||||||
|
}
|
||||||
|
.server-ver-chip--warn { background: var(--tertiary-container); color: var(--on-tertiary-container); }
|
||||||
.server-gen-chip {
|
.server-gen-chip {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -732,3 +746,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; }
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default function App() {
|
|||||||
const [statusText, setStatusText] = useState('');
|
const [statusText, setStatusText] = useState('');
|
||||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||||
const [hubEnabled, setHubEnabled] = useState(false);
|
const [hubEnabled, setHubEnabled] = useState(false);
|
||||||
|
const [panelVersion, setPanelVersion] = useState<string | null>(null);
|
||||||
const [serverId, setServerIdState] = useState<number | null>(null);
|
const [serverId, setServerIdState] = useState<number | null>(null);
|
||||||
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
@@ -55,6 +56,7 @@ export default function App() {
|
|||||||
const list: NodeInfo[] = data.nodes ?? [];
|
const list: NodeInfo[] = data.nodes ?? [];
|
||||||
setNodes(list);
|
setNodes(list);
|
||||||
setHubEnabled(!!data.hub);
|
setHubEnabled(!!data.hub);
|
||||||
|
setPanelVersion(data.version ?? null);
|
||||||
const cur = getServerId();
|
const cur = getServerId();
|
||||||
if (cur !== null && list.some(n => n.id === cur)) return;
|
if (cur !== null && list.some(n => n.id === cur)) return;
|
||||||
let saved: number | null = null;
|
let saved: number | null = null;
|
||||||
@@ -101,8 +103,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) {
|
||||||
@@ -303,6 +313,7 @@ export default function App() {
|
|||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
hubEnabled={hubEnabled}
|
hubEnabled={hubEnabled}
|
||||||
|
panelVersion={panelVersion}
|
||||||
onSelect={pickServer}
|
onSelect={pickServer}
|
||||||
onAdd={() => setModal({})}
|
onAdd={() => setModal({})}
|
||||||
onJoin={n => setModal({ node: n })}
|
onJoin={n => setModal({ node: n })}
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
// 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 { type NodeInfo, genLabel } from '../lib/shared';
|
import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
|
||||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
|
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
|
||||||
|
|
||||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
||||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
||||||
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
||||||
|
// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию
|
||||||
|
// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить».
|
||||||
|
function versionChip(n: NodeInfo, panel: string | null) {
|
||||||
|
if (n.pending) return null;
|
||||||
|
let tip = '', warn = false;
|
||||||
|
if (!n.version) {
|
||||||
|
warn = true;
|
||||||
|
tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)';
|
||||||
|
} else if (panel && !n.local) {
|
||||||
|
const d = cmpVersion(n.version, panel);
|
||||||
|
if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
|
||||||
|
else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; }
|
||||||
|
}
|
||||||
|
const chip = (
|
||||||
|
<span className={`chip server-ver-chip${warn ? ' server-ver-chip--warn' : ''}`}>
|
||||||
|
{n.version ? `v${n.version}` : 'v?.?.?'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return tip ? <span className="tip-wrap server-gen-wrap" data-tip={tip}>{chip}</span> : chip;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ServerBar({
|
export default function ServerBar({
|
||||||
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
|
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete,
|
||||||
onRestartAwg, restarting, showRestart = true,
|
onRestartAwg, restarting, showRestart = true,
|
||||||
}: {
|
}: {
|
||||||
nodes: NodeInfo[];
|
nodes: NodeInfo[];
|
||||||
|
panelVersion: string | null;
|
||||||
serverId: number | null;
|
serverId: number | null;
|
||||||
hubEnabled: boolean;
|
hubEnabled: boolean;
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
@@ -52,9 +74,12 @@ export default function ServerBar({
|
|||||||
) : !n.online ? (
|
) : !n.online ? (
|
||||||
<span className="server-card-ip">нет связи</span>
|
<span className="server-card-ip">нет связи</span>
|
||||||
) : h && (
|
) : h && (
|
||||||
<>
|
|
||||||
<span className="server-card-ip">{h.ip}</span>
|
<span className="server-card-ip">{h.ip}</span>
|
||||||
{(h.module || h.tools) ? (
|
)}
|
||||||
|
{/* Плашки: поколение AWG (только на связи) и версия Forgetting (известна и без связи) */}
|
||||||
|
{!n.pending && (
|
||||||
|
<span className="server-chips">
|
||||||
|
{h && n.online && ((h.module || h.tools) ? (
|
||||||
<span
|
<span
|
||||||
className="tip-wrap server-gen-wrap"
|
className="tip-wrap server-gen-wrap"
|
||||||
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
|
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
|
||||||
@@ -63,8 +88,9 @@ export default function ServerBar({
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
||||||
)}
|
))}
|
||||||
</>
|
{versionChip(n, panelVersion)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
|
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
import { type NodeInfo, genLabel } from '../lib/shared';
|
||||||
|
|
||||||
|
// Выбор набора серверов галочками: серверы мастер-ключа, видимость API-ключа. Набор не
|
||||||
|
// бывает пустым — последний отмеченный сервер снять нельзя. onChange получает новый набор
|
||||||
|
// и сервер, который переключили.
|
||||||
|
export default function ServerPicker({ nodes, selected, onChange }: {
|
||||||
|
nodes: NodeInfo[];
|
||||||
|
selected: number[];
|
||||||
|
onChange: (next: number[], node: NodeInfo, added: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{nodes.map(n => {
|
||||||
|
const on = selected.includes(n.id);
|
||||||
|
const last = on && selected.length === 1;
|
||||||
|
const h = n.health;
|
||||||
|
return (
|
||||||
|
<label className={`server-pick${last ? ' server-pick--locked' : ''}`} key={n.id}>
|
||||||
|
<input type="checkbox" checked={on} disabled={last}
|
||||||
|
onChange={() => onChange(on ? selected.filter(id => id !== n.id) : [...selected, n.id], n, !on)} />
|
||||||
|
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||||
|
<span className="chip-dot" />
|
||||||
|
</span>
|
||||||
|
<span className="server-pick-name">{h?.server || n.name}</span>
|
||||||
|
{h?.ip && <code className="server-pick-ip">{h.ip}</code>}
|
||||||
|
{h?.gen && <span className="server-pick-gen">{genLabel(h.gen)}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имя сервера для подписей: как в сервер-баре, иначе «#id».
|
||||||
|
export const serverName = (nodes: NodeInfo[], id: number): string => {
|
||||||
|
const n = nodes.find(x => x.id === id);
|
||||||
|
return n ? n.health?.server || n.name : `#${id}`;
|
||||||
|
};
|
||||||
@@ -32,7 +32,8 @@ export interface ApiKey {
|
|||||||
id: number;
|
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;
|
||||||
}
|
}
|
||||||
@@ -59,9 +60,20 @@ export interface NodeInfo {
|
|||||||
online: boolean;
|
online: boolean;
|
||||||
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
||||||
last_seen: number | null;
|
last_seen: number | null;
|
||||||
|
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
|
||||||
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
||||||
|
export function cmpVersion(a: string, b: string): number {
|
||||||
|
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||||
|
if (d) return d;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
|
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
|
||||||
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
|
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
|
||||||
// перечитывает данные уже с новым сервером.
|
// перечитывает данные уже с новым сервером.
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; uuid: string; 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);
|
||||||
@@ -94,6 +103,25 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
catch (e) { showMsg(errText(e)); }
|
catch (e) { showMsg(errText(e)); }
|
||||||
}, [fetchLink, showMsg]);
|
}, [fetchLink, showMsg]);
|
||||||
|
|
||||||
|
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
|
||||||
|
// поэтому сперва спросить и назвать, кого именно.
|
||||||
|
const saveLimit = useCallback(async (k: MasterKey) => {
|
||||||
|
const limit = Number(limitEdit);
|
||||||
|
const list = devs[k.id] ?? [];
|
||||||
|
const extra = list.length - limit;
|
||||||
|
if (Number.isInteger(limit) && extra > 0) {
|
||||||
|
const names = [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
|
||||||
|
.map(d => `«${d.device_name || d.id}»`).join(', ');
|
||||||
|
if (!confirm(`Лимит ${limit} меньше, чем устройств (${list.length}). Будут отвязаны подключившиеся последними: ${names}. Продолжить?`)) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit });
|
||||||
|
const n: number = r.unbound?.length ?? 0;
|
||||||
|
showMsg(n ? `Лимит сохранён, отвязано устройств: ${n}` : 'Лимит сохранён');
|
||||||
|
await load();
|
||||||
|
} catch (e) { showMsg(errText(e)); }
|
||||||
|
}, [limitEdit, devs, token, load, showMsg]);
|
||||||
|
|
||||||
const fold = (id: number) => setFolded(prev => {
|
const fold = (id: number) => setFolded(prev => {
|
||||||
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
||||||
});
|
});
|
||||||
@@ -101,6 +129,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>
|
||||||
@@ -115,6 +157,19 @@ 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>
|
||||||
|
<div className="mk-setting">
|
||||||
|
<div className="mk-setting-text">
|
||||||
|
<span className="mk-setting-name">UUID</span>
|
||||||
|
<code className="mk-uuid" title={k.uuid}>{k.uuid}</code>
|
||||||
|
<span className="mk-setting-hint">Постоянный: перевыпуск ссылки его не меняет</span>
|
||||||
|
</div>
|
||||||
|
<div className="mk-setting-ctl">
|
||||||
|
<button className="btn btn--tonal" onClick={() =>
|
||||||
|
copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||||
|
<IcoCopy /> Скопировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mk-setting">
|
<div className="mk-setting">
|
||||||
<div className="mk-setting-text">
|
<div className="mk-setting-text">
|
||||||
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
|
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
|
||||||
@@ -123,8 +178,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<div className="mk-setting-ctl">
|
<div className="mk-setting-ctl">
|
||||||
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
|
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
|
||||||
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
|
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
|
||||||
<button className="btn btn--tonal" onClick={() =>
|
<button className="btn btn--tonal" onClick={() => saveLimit(k)}>
|
||||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: Number(limitEdit) }), 'Лимит сохранён')}>
|
|
||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,27 +213,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>
|
||||||
@@ -216,6 +252,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка),
|
||||||
|
// полностью — в подсказке, по клику копируется.
|
||||||
|
const uuidCell = (k: MasterKey) => (
|
||||||
|
<span className="tip-wrap mk-uuid-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
|
||||||
|
<button className="mk-uuid-btn" aria-label={`Скопировать UUID ${k.uuid}`}
|
||||||
|
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||||
|
<code className="mk-device-id">{k.uuid}</code>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
const caret = (k: MasterKey) => (
|
const caret = (k: MasterKey) => (
|
||||||
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
||||||
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
||||||
@@ -277,6 +324,13 @@ 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 className="td-mono">{uuidCell(k)}</td>
|
||||||
|
<td colSpan={7}>{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);
|
||||||
@@ -284,7 +338,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<Fragment key={k.id}>
|
<Fragment key={k.id}>
|
||||||
<tr className="mk-tr-master">
|
<tr className="mk-tr-master">
|
||||||
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
|
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
|
||||||
<td colSpan={6}></td>
|
<td className="td-mono">{uuidCell(k)}</td>
|
||||||
|
<td colSpan={5}></td>
|
||||||
<td>{meter(k, list.length)}</td>
|
<td>{meter(k, list.length)}</td>
|
||||||
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -324,6 +379,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}<code className="mk-master-uuid">{k.uuid}</code></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);
|
||||||
@@ -332,7 +396,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<div className="mk-master">
|
<div className="mk-master">
|
||||||
{caret(k)}
|
{caret(k)}
|
||||||
<IcoKey />
|
<IcoKey />
|
||||||
<span className="mk-master-label">{k.label}</span>
|
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||||
{meter(k, list.length)}
|
{meter(k, list.length)}
|
||||||
{actions(k)}
|
{actions(k)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
|
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
|
||||||
.mk-tr-master:hover td { background: var(--surface-container); }
|
.mk-tr-master:hover td { background: var(--surface-container); }
|
||||||
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||||
|
/* UUID ключа в строке: выглядит как ID устройства, по клику копирует */
|
||||||
|
.mk-uuid-btn {
|
||||||
|
background: none; border: 0; padding: 0; margin: 0;
|
||||||
|
cursor: pointer; font: inherit; font-weight: 400; color: inherit; line-height: inherit;
|
||||||
|
}
|
||||||
|
.mk-uuid-btn:hover .mk-device-id { color: var(--primary); }
|
||||||
|
.mk-uuid-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
.mk-master-uuid {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--mono); font-size: 12px; font-weight: 400;
|
||||||
|
color: var(--neutral-text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.mk-name .mk-caret { margin-left: -8px; }
|
.mk-name .mk-caret { margin-left: -8px; }
|
||||||
.mk-tr-master .mk-name { color: var(--on-surface); }
|
.mk-tr-master .mk-name { color: var(--on-surface); }
|
||||||
.mk-tr-master .mk-name svg { color: var(--primary); }
|
.mk-tr-master .mk-name svg { color: var(--primary); }
|
||||||
@@ -111,6 +124,10 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
||||||
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
|
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
|
||||||
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||||
|
.mk-uuid {
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
|
||||||
|
}
|
||||||
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
|
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
|
||||||
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
|
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
|
||||||
|
|
||||||
@@ -149,24 +166,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%; }
|
||||||
|
|
||||||
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
||||||
|
|||||||
+360
-146
@@ -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,10 +46,12 @@ 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 {
|
||||||
id: number; label: string; secret: Buffer; device_limit: number;
|
id: number; uuid: string; label: string; secret: Buffer; device_limit: number;
|
||||||
server_id: number; created_at: number; revoked_at: number | null;
|
server_id: number; created_at: number; revoked_at: number | null;
|
||||||
}
|
}
|
||||||
interface DeviceRow {
|
interface DeviceRow {
|
||||||
@@ -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,11 +186,25 @@ 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
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
|
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
|
||||||
// таблицы колонки может не быть.
|
// таблицы колонки может не быть.
|
||||||
if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version"))
|
if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version"))
|
||||||
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
|
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
|
||||||
|
// uuid мастер-ключа — постоянный идентификатор: не меняется ни при перевыпуске ссылки,
|
||||||
|
// ни при правке метки. Существующим ключам выдаётся здесь один раз.
|
||||||
|
if (!(uidb.prepare("PRAGMA table_info(master_keys)").all() as { name: string }[]).some(c => c.name === "uuid"))
|
||||||
|
uidb.exec("ALTER TABLE master_keys ADD COLUMN uuid TEXT");
|
||||||
|
{
|
||||||
|
const setUuid = uidb.prepare<[string, number]>("UPDATE master_keys SET uuid = ? WHERE id = ?");
|
||||||
|
for (const { id } of uidb.prepare("SELECT id FROM master_keys WHERE uuid IS NULL").all() as { id: number }[])
|
||||||
|
setUuid.run(crypto.randomUUID(), id);
|
||||||
|
}
|
||||||
|
uidb.exec("CREATE UNIQUE INDEX IF NOT EXISTS master_keys_uuid ON master_keys (uuid)");
|
||||||
uidb.pragma("foreign_keys = ON");
|
uidb.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
|
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
|
||||||
@@ -185,20 +213,39 @@ 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, string, Buffer, number, number, number]>("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)"),
|
||||||
|
masterIdByUuid: uidb.prepare<[string]>("SELECT id FROM master_keys WHERE uuid = ?"),
|
||||||
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 +275,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 +347,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 +378,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 +471,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 +544,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 +572,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,13 +582,13 @@ 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, key_uuid: master.uuid,
|
||||||
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 +597,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, { key_uuid: master.uuid, 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 +614,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,9 +622,10 @@ 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,
|
||||||
|
key_uuid: master.uuid,
|
||||||
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 => ({
|
||||||
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
|
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
|
||||||
@@ -529,10 +639,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 +650,117 @@ 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 — не найдётся
|
||||||
|
// Мастер-ключ в пути — числовой id или uuid.
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const masterIdOf = (v: string) => UUID_RE.test(v)
|
||||||
|
? (q.masterIdByUuid.get(v.toLowerCase()) as { id: number } | undefined)?.id ?? -1
|
||||||
|
: idOf(v);
|
||||||
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({
|
id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit,
|
||||||
enabled: signKey !== null,
|
|
||||||
tls: tlsPin !== null,
|
|
||||||
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
|
|
||||||
id: m.id, label: m.label, device_limit: m.device_limit,
|
|
||||||
devices: m.devices, 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(crypto.randomUUID(), 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 +780,60 @@ 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);
|
||||||
|
// Лимит стал меньше, чем устройств: лишние отвязываются, начиная с подключившихся последними, —
|
||||||
|
// первые, кто занял места, их сохраняют. Как отзыв в панели: пиры снимаются, устройство получит 410.
|
||||||
|
const unbound: number[] = [];
|
||||||
|
const devices = q.devicesOf.all(m.id) as DeviceRow[];
|
||||||
|
if (devices.length > (nd as number)) {
|
||||||
|
const newest = [...devices].sort((a, b) => b.created_at - a.created_at || b.id - a.id);
|
||||||
|
for (const d of newest.slice(0, devices.length - (nd as number))) {
|
||||||
|
affected.push(...forgetDevice(d));
|
||||||
|
unbound.push(d.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, unbound };
|
||||||
}));
|
}
|
||||||
|
|
||||||
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 +846,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 +855,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(masterIdOf(req.params.id), null), req.body, null)); }));
|
||||||
|
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
|
||||||
|
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
|
||||||
|
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(masterIdOf(req.params.id), null))); }));
|
||||||
|
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(masterIdOf(req.params.id), null), req)); }));
|
||||||
|
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(masterIdOf(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(masterIdOf(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 +963,5 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { masterKeys, devices, start };
|
return { masterKeys, devices, apiRouter, start };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-cli",
|
"name": "awg-cli",
|
||||||
"version": "0.3.2",
|
"version": "0.3.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
+140
-1
@@ -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
@@ -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.4"
|
||||||
CHANNEL="${CHANNEL:-Beta}"
|
CHANNEL="${CHANNEL:-Beta}"
|
||||||
|
|
||||||
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
|
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
|
||||||
|
|||||||
Reference in New Issue
Block a user