12 Commits
Author SHA1 Message Date
maeneko 10b46cbc2e Bump to Beta 0.3.2
ci / check (push) Successful in 5m55s
release / release (push) Successful in 3m4s
All seven places the version is written down: .env, the copy in
install.sh, the four package.json files and the /ui/brand example in the
README.

A master key can serve its devices from several servers; a server that
joins a core as a node keeps its own panel.
2026-09-26 21:44:25 +03:00
maeneko c97acc933f Serve a master key's devices from several servers
A master key now has a set of servers (the panel's own and/or nodes). A
device's single WireGuard key becomes a peer on every server of its key,
each with its own IP and PSK, and config.servers[] lists them all.

- ui.db: master_servers (the key's set, never empty), device_peers (which
  key is on which server, with the ip/psk that server issued),
  peer_tombstones (peers to delete) and server_profiles (last known
  profile of each server).
- Nothing is pushed blindly: reconcile() brings one server to the desired
  state - deletes tombstones, adds peers, rekeys with PUT, fills ip/psk,
  rotates a pending PSK - one pass per server at a time. It runs right
  after every action, when a node connects and once a minute, so a node
  that was offline catches up on rekeys, PSK changes and removals.
- config is built from ui.db, so an offline node stays in it and rev does
  not flap; a server is listed once its peer carries the device's current
  key. An empty config is never sent (502 instead); register and rekey
  roll back if no server accepted the key.
- Existing keys and devices migrate on start. With one server the config
  and its rev are byte-identical to 0.3.1 (checked by running both
  versions on the same ui.db), so existing clients notice nothing.
- Panel: server checkboxes in the key settings instead of the placeholder
  (the last one cannot be removed), "на N из M серверов" on devices that
  have not reached every server yet, device stats summed over servers.
- SUB_HOST: the panel's public address for sen:// links and endpoints;
  install.sh writes it. Without it a panel with no VPN of its own used
  the address it was opened at.
- A node referenced by a key's server set cannot be deleted.
2026-09-26 21:43:58 +03:00
maeneko a65dfeeae9 Keep the node's own panel when it joins a core
A server that already works as panel + VPN can now also be a node of
another panel without losing its own: `awg-ctrl join` keeps awg-ui running
next to the agent and writes NODE_PANEL=on. `--no-panel` stops it as
before. Both panels talk to the same awg-ctrl, which stays the source of
truth, so they do not get in each other's way.

The CLI runs awg-ctrl, awg-ui and awg-agent on such a node, all behind the
awg-ctrl health gate; a node installed by install.sh has no UI_PASS and
still runs only awg-ctrl + agent. unjoin also drops NODE_PANEL.
2026-09-26 21:43:45 +03:00
maeneko 1e73aaae6a Bump to Beta 0.3.1
ci / check (push) Successful in 5m59s
release / release (push) Successful in 3m10s
Covers all seven places the version is written down: .env, the copy
install.sh keeps because it prints its banner and builds the release URL
before the archive with .env exists, the four package.json files (awg-agent
is new), and the /ui/brand example in the README.

Multiple servers under one panel (core + nodes), the installer's role
question and its full log.
2026-09-26 20:19:50 +03:00
maeneko e284ec5609 Fix the installer's generated password, keep going if the log dies
Found by running install.sh end to end in a sandbox for all three roles.

- The suggested UI password and JWT_SECRET came from
  `tr < /dev/urandom | head -c N || openssl rand`. Under pipefail tr's
  SIGPIPE made the pipeline fail every time, so openssl ran too and the two
  outputs were glued together (a 24-character password). rand_alnum() reads
  a finite 1 KiB instead.
- The log filter runs sed with LC_ALL=C, so broken UTF-8 in someone else's
  output (make.log, dmesg) cannot stop it, and tee -p keeps writing to the
  screen if the log writer dies instead of taking the installer down with a
  silent SIGPIPE.
- Output: labels before awg-quick up and apt-mark hold, no double blank
  lines, "маршрут" only where there is a VPN, a hint when the agent log is
  still empty.
2026-09-26 20:19:44 +03:00
maeneko ae0b4e39e4 Ask for the server role in the installer, log everything
- install.sh asks the role first: panel + VPN (as before), panel only
  (skips AmneziaWG, the kernel module and the headers/virt checks) or node
  (no panel; takes the awgjoin:// string and checks the panel's cert pin
  with openssl before building anything). ROLE= / JOIN= skip the questions;
  an update keeps the previous role.
- The previous cli.env is read before a reinstall removes the project
  directory. Before this, keeping data silently lost SUB_PORT / SUB_TLS
  and broke every issued sen:// link.
- Output: a header, numbered steps, menus, a summary before installing and
  a final block per role. The full output of apt, DKMS, npm, awg-quick,
  ufw and systemctl is shown as a dimmed block under its step, including
  what used to go to /dev/null.
- The log starts with the first line: checks, questions with answers
  (password masked), all command output. It is written without colour
  codes, mode 600.
2026-09-26 19:48:34 +03:00
maeneko 388222116a Manage several servers from one panel: core + nodes
A node is a VPN server without a panel. awg-agent on it dials the panel
(core) over an outbound WSS connection and runs the panel's requests on its
own awg-ctrl, which stays on 127.0.0.1 and is unchanged. No inbound port on
the node, works behind NAT.

- awg-ui/nodes.ts: node registry in ui.db, WSS hub on NODE_PORT with a
  self-signed cert whose SPKI pin goes into the join string, RPC with
  timeouts, health polling, /ui/nodes routes.
- Nodes enroll with a one-time awgjoin:// string (secret stored hashed,
  1 hour) and then authenticate by their own Ed25519 key. The agent checks
  the pin before sending anything and only forwards /health, /api/*, /awg/*.
- server_id 0 is the panel's own awg-ctrl; LOCAL_NODE=off makes the panel
  run without a VPN of its own. The panel picks a server with X-Server-Id,
  API keys use their server_id. sen:// still serves one server.
- UI: a card per server in the server bar, switching remounts the tab,
  «Добавить сервер» gives the join command.
- CLI: agent service, roles from cli.env, join / unjoin commands.
- Release archives include awg-agent.
2026-09-26 19:48:33 +03:00
maeneko 7019490e04 Add /sub/v1/devices and /sub/v1/peek, count the slots on register
ci / check (push) Successful in 5m57s
release / release (push) Successful in 3m15s
- GET /sub/v1/devices: a device lists the devices of its own master key
  (name, platform, version, last seen, and which one is asking). Read-only:
  unbinding is still only DELETE /sub/v1/device for oneself.
- POST /sub/v1/peek: how many slots of a key are taken, asked with the
  link's secret before any device exists, so the client can show it while
  the link is only pasted. Wrong secrets count against the failure limit.
- register also answers with devices / device_limit, so the client needs no
  second request to show the slot it just took.
2026-09-25 17:24:52 +03:00
maeneko 08908b002c Bump to Beta 0.3.0
release / release (push) Successful in 3m12s
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.

First release with the sen:// subscription and master keys.
2026-09-25 17:24:52 +03:00
maeneko 6e3959d16c Add sen:// subscription with master keys
A master key is a sen:// link with a device limit. Each device
generates its own WireGuard keypair and registers only the public key,
then polls a signed /sub/v1/config, so params and generation changes
reach users without re-importing a key.

- awg-ctrl: peers table and /api/peers*, /api/profile; nextIp and
  rebuildConf take peers into account (output unchanged without them)
- awg-ui: link codec and signatures (sen.ts), registry, panel routes
  and the public listener on SUB_PORT (sub.ts)
- responses are always Ed25519-signed; TLS with a pinned self-signed
  certificate is optional
- devices report a client version, shown as platform-version
- "Мастер-ключи" tab: a table with the devices nested under each key,
  a device-limit meter, per-key settings and a placeholder for the
  servers a key serves; cards on mobile
- install.sh/CLI: subscription signing key, optional certificate, port;
  kept across reinstalls with KEEP_DATA
- add the missing mock-server.ts used by npm run dev:mock
2026-09-25 17:24:52 +03:00
maenekoandClaude Opus 5 fd87884bde Bump to Beta 0.2.2
ci / check (push) Successful in 7m1s
release / release (push) Successful in 3m40s
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:51:19 +03:00
maenekoandClaude Opus 5 4971a90e41 Support Debian in the installer
install.sh only knew Ubuntu. On Debian 13 the dependency step died on
software-properties-common, which trixie no longer ships, and on
linux-headers-generic, which Debian never had — so add-apt-repository and the
Ubuntu headers meta package are both off the table there.

The distro family now comes from /etc/os-release (derivatives via ID_LIKE) and
shows up as its own line in the compatibility checklist. On Debian the amnezia
PPA is wired by hand: the key is fetched from keyserver.ubuntu.com by its full
fingerprint and checked after download, then a signed-by source is written on
the Ubuntu series whose glibc is not newer than the Debian release's
(bullseye→focal, bookworm→jammy, trixie→noble; AMNEZIA_PPA_SUITE overrides).

Headers are installed as a separate step so a missing package gets precise
advice instead of a generic failure. Debian's archive keeps only the latest
kernel build, so a server that hasn't rebooted in a while has no headers for
its running kernel; the hint names linux-image/linux-headers for the kernel's
own flavour (cloud-amd64 and so on) plus a reboot.

iptables joins the dependencies — PostUp calls it, and a minimal Debian 13 on
nftables doesn't have it. IP forwarding moves to its own file in
/etc/sysctl.d: Debian 13 no longer reads /etc/sysctl.conf at boot, so the old
line would have held only until the first reboot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:51:19 +03:00
24 changed files with 301 additions and 1152 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.3.4
VERSION=0.3.2
-1
View File
@@ -4,7 +4,6 @@
**/node_modules/
**/dist/
cli/cli.env
awg-ui/panel.id
logs/
npm-debug.log*
yarn-debug.log*
+3 -53
View File
@@ -83,39 +83,6 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить»
ключ = удалить и создать заново.
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка;
`0` — сервер самой панели, остальные — подключённые ноды). Пользователи `vpn://`
заводятся на сервере ключа по умолчанию; другой сервер из набора выбирается
заголовком `X-Server-Id: <id>`, сервер вне набора → `404`.
#### Мастер-ключи `sen://`
Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое
устройство само регистрируется по ссылке и получает пира на всех серверах ключа,
его приватный ключ сервер не видит. API-ключ видит и меняет мастер-ключ, только
если **все** его серверы входят в набор API-ключа; остальные для него не
существуют (`404`).
```bash
curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
-H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \
-d '{"label":"Семья","device_limit":3}' | jq -r .link
```
| Метод | Что делает |
|---|---|
| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, 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_` |
@@ -125,9 +92,8 @@ curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне
каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про
внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией.
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем
`servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
перевыпуская ключ, и `DELETE /ui/apikeys/:id`. Подробно — `awg-ui/API.md`.
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` и
`DELETE /ui/apikeys/:id`.
### Авторизация панели
@@ -149,7 +115,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.2" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
@@ -190,22 +156,6 @@ base64url-декодировать, отбросить первые 4 байта
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
поднимается обратно на 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
-11
View File
@@ -23,16 +23,6 @@ const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnez
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
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) {
console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`");
process.exit(1);
@@ -148,7 +138,6 @@ function connectOnce(): Promise<{ authFailed: boolean }> {
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
};
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
if (VERSION) auth.version = VERSION;
ws.send(JSON.stringify(auth));
} else if (m.t === "ready") {
ready = true; backoff = 1000; armDead();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-agent",
"version": "0.3.4",
"version": "0.3.2",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
+1 -54
View File
@@ -305,7 +305,6 @@ const stmts = {
peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"),
peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"),
peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"),
peerOwner: db.prepare<[string, string]>("UPDATE peers SET owner = ? WHERE pub_key = ?"),
userPubTaken: db.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?"),
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
all: db.prepare<[], UserRow>("SELECT * FROM users"),
@@ -947,52 +946,8 @@ function removePeer(pub: string) {
logger.info("peer removed", { owner: peer.owner });
}
// Сведение пиров одной панели: панель присылает полный список ключей, которые ей нужны
// на этом сервере, — всё остальное из её области («<panel>/…») снимается. Удаляем только
// по такому явному списку, никогда по времени или неактивности: панель не на связи —
// ничего не происходит. Пиры без области (owner «m1/d2», выданы до областей) панель
// забирает себе, только если их ключ есть в её списке; остальные не трогаем — чьи они,
// уже не разобрать.
const PANEL_ID = /^[a-z0-9]{8,16}$/;
const LEGACY_OWNER = /^m\d+\/d\d+$/;
const SYNC_MAX = 10_000;
function syncOwnedPeers(panel: string, keep: string[]): {
present: string[]; removed: number; legacy: { pub_key: string; owner: string; ip: string }[];
} {
const want = new Set(keep);
const prefix = panel + "/";
const removed: PeerRow[] = [];
let claimed = 0;
db.transaction(() => {
for (const p of stmts.peersAll.all()) {
if (p.owner.startsWith(prefix)) {
if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
claimed++;
}
}
})();
for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]);
if (removed.length || claimed) rebuildConf();
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
const all = stmts.peersAll.all();
return {
present: all.filter(p => p.owner.startsWith(prefix)).map(p => p.pub_key),
removed: removed.length,
legacy: all.filter(p => LEGACY_OWNER.test(p.owner)).map(p => ({ pub_key: p.pub_key, owner: p.owner, ip: p.ip })),
};
}
const app = express();
// Сведение пиров несёт полный список ключей панели — ему лимит побольше, остальным хватает 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));
app.use(express.json({ limit: "1kb" }));
function verifyInternalToken(token: string): boolean {
try {
@@ -1115,14 +1070,6 @@ app.get("/api/peers", auth, handler((_req, res) => {
});
}));
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
app.put(SYNC_PATH, auth, handler((req, res) => {
const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown };
if (typeof owner !== "string" || !PANEL_ID.test(owner)) throw new HttpError(400, "Неверный owner");
if (!Array.isArray(keep) || keep.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
res.json(syncOwnedPeers(owner, keep.map(checkPubKey)));
}));
app.put("/api/peers/:pub", auth, handler((req, res) => {
const { pub_key } = (req.body ?? {}) as { pub_key?: string };
res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key)));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.3.4",
"version": "0.3.2",
"description": "",
"main": "dist/index.js",
"scripts": {
+19 -45
View File
@@ -15,7 +15,6 @@ const now = () => Math.floor(Date.now() / 1000);
const rnd = (n: number) => Math.floor(Math.random() * n);
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
const GEN = "3.1";
const MOCK_VERSION = "0.3.3";
// ── пользователи (vpn://) ──
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
@@ -35,32 +34,31 @@ 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 U_ = (q: express.Request) => usersOf[srv(q)];
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null }
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean }
const mnodes: MNode[] = [
{ 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, version: null },
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: true, pending: false, version: null }, // старый агент: версию не шлёт
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false },
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true },
];
let nodeId = 3;
let nodeId = 2;
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
// ── API-ключи ──
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, servers: [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, created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }];
let apiId = 1;
// ── мастер-ключи sen:// ──
interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number }
interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[] }
let mkId = 1, devId = 1;
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
const masters: MK[] = [{ id: mkId++, 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 masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, 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 } : {}) });
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 });
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
const find = (id: string) => masters.find(m => m.id === Number(id));
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (q, r) => {
@@ -70,28 +68,19 @@ app.get("/health", (q, r) => {
// ── серверы ──
const nodeView = () => [
{ 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, version: n.version,
{ 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" } },
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : 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, version: MOCK_VERSION, nodes: nodeView() }));
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() }));
app.post("/ui/nodes", (q, r) => {
const name = String(q.body.name ?? "");
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, version: null });
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true });
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 });
});
app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); });
app.delete("/ui/nodes/:id", (q, r) => {
const 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.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.post("/awg/restart", (_q, r) => r.json({ up: true }));
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
@@ -110,39 +99,24 @@ app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q.
app.delete("/api/users/:name", (q, r) => { const users = U_(q); const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys }));
app.post("/ui/apikeys", (q, r) => { const 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.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3;
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), 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++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
masters.push(m); r.status(201).json(mkOut(m));
});
app.patch("/ui/masterkeys/:id", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
if (q.body.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;
// Как 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 });
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m));
});
app.post("/ui/masterkeys/:id/rotate", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.secret = crypto.randomBytes(16); r.json({ id: m.id }); });
app.post("/ui/masterkeys/:id/rekey", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.devs.forEach(d => d.rekey_requested = true); r.json({ id: m.id }); });
app.delete("/ui/masterkeys/:id", (q, r) => {
const 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.delete("/ui/masterkeys/:id", (q, r) => { const i = masters.findIndex(m => m.id === Number(q.params.id)); if (i >= 0) masters.splice(i, 1); r.json({ success: true }); });
app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
+15 -35
View File
@@ -25,7 +25,7 @@ export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<
interface NodeRow {
id: number; name: string; pub: string | null;
join_hash: Buffer | null; join_expires: number | null;
created_at: number; last_seen: number | null; version: string | null;
created_at: number; last_seen: number | null;
}
interface Health {
@@ -46,12 +46,11 @@ const RPC_TIMEOUT = 15_000;
const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго
const NAME_RE = /^[^\r\n<>]{1,40}$/;
const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/;
const VERSION_RE = /^[\w.+-]{1,32}$/;
// Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent.
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; version: string }) {
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string }) {
const { uidb, ctrlLocal } = deps;
const NODE_PORT = Number(process.env.NODE_PORT) || 0;
@@ -95,10 +94,6 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
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 = {
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
@@ -106,7 +101,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
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"),
bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"),
seen: uidb.prepare<[number, string | null, number]>("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?"),
seen: uidb.prepare<[number, number]>("UPDATE nodes SET last_seen = ? WHERE id = ?"),
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
};
const now = () => Math.floor(Date.now() / 1000);
@@ -153,16 +148,6 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const onlineHandlers: ((id: number) => void)[] = [];
const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); };
// Подписчики на «сервер удаляют». beforeDelete — проверка: может запретить удаление и не
// меняет ui.db (запретить может и следующий подписчик). forcible — запрет снимается
// подтверждением оператора (force): сервер не всё успел подтвердить, а не используется.
// afterDelete — уборка своих ссылок на сервер, когда он уже удалён.
type DeleteVeto = { error: string; forcible: boolean } | null;
const deleteHandlers: ((id: number, force: boolean) => Promise<DeleteVeto>)[] = [];
const beforeDelete = (cb: (id: number, force: boolean) => Promise<DeleteVeto>) => { deleteHandlers.push(cb); };
const afterHandlers: ((id: number) => void)[] = [];
const afterDelete = (cb: (id: number) => void) => { afterHandlers.push(cb); };
/** Сервер по умолчанию: локальный, а без него — первая нода. */
function defaultId(): number {
if (LOCAL) return 0;
@@ -230,9 +215,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
clearTimeout(authTimer);
if (joining) q.bind.run(pubB64 as string, row.id);
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
const version = typeof m.version === "string" && VERSION_RE.test(m.version) ? m.version : null;
q.seen.run(now(), version, row.id);
q.seen.run(now(), row.id);
const old = conns.get(row.id);
conn = { ws, seq: 0, alive: true, pending: new Map() };
@@ -310,15 +293,15 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const nodes: unknown[] = [];
if (LOCAL) {
const h = health.get(0) ?? null;
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, health: h });
}
for (const n of q.all.all() as NodeRow[]) {
nodes.push({
id: n.id, name: n.name, local: false, online: conns.has(n.id),
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
pending: n.pub === null, last_seen: n.last_seen, health: health.get(n.id) ?? null,
});
}
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes });
res.json({ hub: hubReady(), local: LOCAL, nodes });
});
router.post("/", (req: Request, res: Response) => {
@@ -356,18 +339,16 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
res.json({ id, name });
});
router.delete("/:id", async (req: Request, res: Response) => {
router.delete("/:id", (req: Request, res: Response) => {
const id = idOf(req);
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
// Ключи API и мастер-ключи проверяют их владельцы в своих обработчиках (beforeDelete).
const force = req.query.force === "1";
for (const cb of deleteHandlers) {
let veto: DeleteVeto;
try { veto = await cb(id, force); } catch { veto = { error: "Не удалось подготовить сервер к удалению", forcible: true }; }
if (veto) { res.status(409).json({ error: veto.error, can_force: veto.forcible && !force }); return; }
}
if (!q.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
// Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе.
const used = (["api_keys", "master_servers"] as const).some(t => {
try { return (uidb.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE server_id = ?`).get(id) as { n: number }).n > 0; }
catch { return false; }
});
if (used) { res.status(409).json({ error: "К серверу привязаны ключи API или мастер-ключи — сначала удалите их" }); return; }
const c = conns.get(id);
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
q.delete.run(id);
@@ -375,9 +356,8 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
for (const t of ["peer_tombstones", "server_profiles"]) {
try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ }
}
for (const cb of afterHandlers) { try { cb(id); } catch (e) { console.warn(`nodes: уборка после удаления #${id}: ${(e as Error).message}`); } }
res.json({ success: true, id });
});
return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, afterDelete, defaultId, start };
return { router, ctrlFor, exists, isOnline, ids, onOnline, defaultId, start };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.3.4",
"version": "0.3.2",
"private": true,
"scripts": {
"dev": "vite",
+12 -97
View File
@@ -73,56 +73,17 @@ uidb.exec(`
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_used INTEGER
);
-- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах
-- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users,
-- всегда входит в набор.
CREATE TABLE IF NOT EXISTS api_key_servers (
key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
PRIMARY KEY (key_id, server_id)
);
-- Ключи со времён одного сервера: набор — их server_id.
INSERT OR IGNORE INTO api_key_servers (key_id, server_id)
SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers);
)
`);
const keyStmts = {
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
byId: uidb.prepare<[number]>("SELECT * FROM api_keys WHERE id = ?"),
setDefault: uidb.prepare<[number, number]>("UPDATE api_keys SET server_id = ? WHERE id = ?"),
serversOf: uidb.prepare<[number]>("SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id"),
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)"),
serversClear: uidb.prepare<[number]>("DELETE FROM api_key_servers WHERE key_id = ?"),
keysOnServer: uidb.prepare<[number]>(
"SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?"),
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
};
const keyServers = (id: number) => (keyStmts.serversOf.all(id) as { server_id: number }[]).map(r => r.server_id);
// Набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в наборе, иначе —
// наименьший из набора.
function setKeyServers(id: number, ids: number[]) {
const row = keyStmts.byId.get(id) as ApiKeyRow;
uidb.transaction(() => {
keyStmts.serversClear.run(id);
for (const sid of ids) keyStmts.serverAdd.run(id, sid);
if (!ids.includes(row.server_id)) keyStmts.setDefault.run(ids[0], id);
})();
}
// Набор серверов из тела запроса: непустой, без повторов, только существующие.
function parseKeyServers(v: unknown): number[] | string {
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
const ids = [...new Set(v)];
if (!ids.every(id => Number.isInteger(id) && nodes.exists(id as number))) return "Такого сервера нет";
return (ids as number[]).sort((a, b) => a - b);
}
function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex");
}
@@ -212,43 +173,26 @@ app.get("/ui/brand", (_req: Request, res: Response) => {
// Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут.
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
res.json({ keys: (keyStmts.list.all() as ApiKeyRow[]).map(k => ({ ...k, servers: keyServers(k.id) })) });
res.json({ keys: keyStmts.list.all() });
});
// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию.
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
const { label, server_id, servers } = req.body as { label?: string; server_id?: number; servers?: unknown };
const { label, server_id } = req.body as { label?: string; server_id?: number };
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
const ids = parseKeyServers(servers ?? [server_id === undefined ? nodes.defaultId() : Number(server_id)]);
if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
const sid = server_id !== undefined && ids.includes(Number(server_id)) ? Number(server_id) : ids[0];
const sid = server_id === undefined ? nodes.defaultId() : Number(server_id);
if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; }
const { key, hash, prefix } = genApiKey();
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);
})();
const info = keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000));
// Открытое значение отдаётся один раз — дальше в БД только хэш.
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 });
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
});
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
uidb.transaction(() => { keyStmts.serversClear.run(id); keyStmts.delete.run(id); })();
keyStmts.delete.run(id);
res.json({ success: true, id });
});
@@ -270,7 +214,7 @@ async function proxy(req: Request, res: Response) {
}
}
interface ExtRequest extends Request { apiKey?: ApiKeyRow; servers?: number[]; serverId?: number; }
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
function requireApiKey(req: Request, res: Response, next: NextFunction) {
const key = (req.headers["x-api-key"] as string) ?? "";
@@ -279,18 +223,6 @@ function requireApiKey(req: Request, res: Response, next: NextFunction) {
if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
(req as ExtRequest).apiKey = row;
(req as ExtRequest).servers = keyServers(row.id);
next();
}
// Сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка — сервер ключа по
// умолчанию. Сервер вне набора — как несуществующий.
function pickKeyServer(req: Request, res: Response, next: NextFunction) {
const r = req as ExtRequest;
const raw = req.headers["x-server-id"];
const id = raw === undefined ? r.apiKey!.server_id : Number(raw);
if (!Number.isInteger(id) || !r.servers!.includes(id)) { res.status(404).json({ error: "Сервер не найден" }); return; }
r.serverId = id;
next();
}
@@ -311,9 +243,8 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
const ext = express.Router();
ext.use(requireApiKey);
// vpn://-пользователи живут на одном сервере — на выбранном pickKeyServer.
ext.use("/users", pickKeyServer);
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).serverId ?? 0);
// Ключ привязан к server_id — все вызовы уходят на его сервер.
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0);
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
@@ -355,29 +286,13 @@ app.use("/api/v1", ext);
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname, version: VERSION });
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
app.use("/ui/nodes", requireAuth, nodes.router);
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices);
// Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован
// на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже.
ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? []));
// Сервер удаляют из реестра: ключ API, для которого он единственный, — повод отказать
// (ключ остался бы без серверов); у остальных он убирается из набора, когда сервер удалён.
type KeyOnServer = { id: number; label: string; server_id: number; n: number };
nodes.beforeDelete(async id => {
const only = (keyStmts.keysOnServer.all(id) as KeyOnServer[]).filter(k => k.n === 1).map(k => k.label);
return only.length
? { error: `Это единственный сервер ключей API: ${only.join(", ")} — сначала удалите их или добавьте им другой сервер`, forcible: false }
: null;
});
nodes.afterDelete(id => {
for (const k of keyStmts.keysOnServer.all(id) as KeyOnServer[]) setKeyServers(k.id, keyServers(k.id).filter(s => s !== id));
});
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
-34
View File
@@ -25,8 +25,6 @@ html, body, #root { height: 100%; }
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
--warn-dot: #e08a2c;
--tertiary-container: #ffdcad; /* design.md: tertiary-container */
--on-tertiary-container: #7a4a14;
--danger-border: #e6c4bc;
--danger-text: #b34a3a;
@@ -69,8 +67,6 @@ html, body, #root { height: 100%; }
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
--warn-dot: #ffb76b;
--tertiary-container: #4a3218; /* тёплый тёмный, как у статусов в тёмной теме */
--on-tertiary-container: #ffdcad;
--danger-border: #6e4540;
--danger-text: #ffb4ab;
@@ -453,16 +449,6 @@ body {
line-height: 1.2;
}
.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 {
align-self: flex-start;
margin-top: 2px;
@@ -746,23 +732,3 @@ tbody td:last-child { padding-right: 20px; }
}
.qr-img { width: 100%; height: auto; aspect-ratio: 1; }
}
/* Выбор набора серверов галочками (components/ServerPicker): серверы мастер-ключа,
видимость API-ключа */
.server-pick {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container);
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
color: var(--on-surface);
cursor: pointer;
}
.server-pick input { accent-color: var(--primary); margin: 0; }
.server-pick:hover { background: var(--state-primary); }
.server-pick--locked { cursor: default; }
.server-pick--locked:hover { background: var(--surface-container); }
.server-pick-dot { padding: 0; background: none; }
.server-pick-name { font-weight: 500; }
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
.server-pick-ip { font-family: var(--mono); margin-left: auto; }
+1 -12
View File
@@ -23,7 +23,6 @@ export default function App() {
const [statusText, setStatusText] = useState('');
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [hubEnabled, setHubEnabled] = useState(false);
const [panelVersion, setPanelVersion] = useState<string | null>(null);
const [serverId, setServerIdState] = useState<number | null>(null);
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
const [msg, setMsg] = useState('');
@@ -56,7 +55,6 @@ export default function App() {
const list: NodeInfo[] = data.nodes ?? [];
setNodes(list);
setHubEnabled(!!data.hub);
setPanelVersion(data.version ?? null);
const cur = getServerId();
if (cur !== null && list.some(n => n.id === cur)) return;
let saved: number | null = null;
@@ -104,15 +102,7 @@ export default function App() {
const deleteNode = useCallback(async (n: NodeInfo) => {
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
try {
try {
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
} catch (e) {
// Сервер не подтвердил, что снял пиров удалённых мастер-ключей (не в сети) —
// панель объясняет, что останется, и удаляет только с согласия.
const data = axios.isAxiosError(e) ? e.response?.data : undefined;
if (!data?.can_force || !confirm(`${data.error}\n\nВсё равно удалить «${n.name}»?`)) throw e;
await apiFetch('DELETE', `/ui/nodes/${n.id}?force=1`, token);
}
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
showMsg('Сервер удалён');
await loadNodes(token);
} catch (e) {
@@ -313,7 +303,6 @@ export default function App() {
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
panelVersion={panelVersion}
onSelect={pickServer}
onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })}
+7 -33
View File
@@ -1,39 +1,17 @@
// 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, cmpVersion } from '../lib/shared';
import { type NodeInfo, genLabel } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск 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({
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete,
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
onRestartAwg, restarting, showRestart = true,
}: {
nodes: NodeInfo[];
panelVersion: string | null;
serverId: number | null;
hubEnabled: boolean;
onSelect: (id: number) => void;
@@ -74,12 +52,9 @@ export default function ServerBar({
) : !n.online ? (
<span className="server-card-ip">нет связи</span>
) : h && (
<span className="server-card-ip">{h.ip}</span>
)}
{/* Плашки: поколение AWG (только на связи) и версия Forgetting (известна и без связи) */}
{!n.pending && (
<span className="server-chips">
{h && n.online && ((h.module || h.tools) ? (
<>
<span className="server-card-ip">{h.ip}</span>
{(h.module || h.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
@@ -88,9 +63,8 @@ export default function ServerBar({
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
))}
{versionChip(n, panelVersion)}
</span>
)}
</>
)}
</div>
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
-41
View File
@@ -1,41 +0,0 @@
// 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}`;
};
+1 -13
View File
@@ -32,8 +32,7 @@ export interface ApiKey {
id: number;
label: string;
prefix: string;
server_id: number; // сервер по умолчанию для /api/v1/users
servers: number[]; // какие серверы видит ключ
server_id: number;
created_at: number;
last_used: number | null;
}
@@ -60,20 +59,9 @@ export interface NodeInfo {
online: boolean;
pending: boolean; // нода создана, но ещё ни разу не подключалась
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;
}
// Сравнение версий вида 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
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
// перечитывает данные уже с новым сервером.
-15
View File
@@ -75,18 +75,3 @@ LICENSE file in the root directory of this source tree.
@media (max-width: 640px) {
.apikey-cards-list { display: flex; }
}
/* Настройки ключа: какие серверы он видит */
.apikey-tr-tools td { padding-top: 0; }
.apikey-tools {
display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: 16px;
padding: 12px 16px 14px;
margin: 6px 0 8px;
}
.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
+29 -79
View File
@@ -1,57 +1,29 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
// управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор
// правится в настройках ключа.
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
// серверов — ключ будет привязываться к выбранному в сервер-баре.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch {
showMsg('Ошибка загрузки ключей');
}
}, [token, showMsg]);
const setServers = useCallback(async (k: ApiKey, next: number[], n: NodeInfo, added: boolean) => {
try {
await apiFetch('PATCH', `/ui/apikeys/${k.id}`, token, { servers: next });
showMsg(added ? `Ключ видит «${serverName(nodes, n.id)}»` : `«${serverName(nodes, n.id)}» убран из ключа`);
await load();
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
}
}, [token, nodes, load, showMsg]);
const serversText = (k: ApiKey) => (k.servers ?? [k.server_id]).map(id => serverName(nodes, id)).join(', ');
const toolbox = (k: ApiKey) => (
<section className="apikey-tools">
<h4 className="apikey-tools-title">Серверы ключа</h4>
<p className="apikey-tools-hint">
На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой
выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.
</p>
<ServerPicker nodes={nodes} selected={k.servers ?? [k.server_id]} onChange={(next, n, added) => setServers(k, next, n, added)} />
</section>
);
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
@@ -72,7 +44,8 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
return (
<>
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
<div className="toolbar">
<input
className="field"
@@ -95,7 +68,6 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Серверы</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
@@ -103,36 +75,24 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={7} className="empty">Нет ключей</td></tr>
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<Fragment key={k.id}>
<tr>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{serversText(k)}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon"
title="Серверы ключа"
aria-label="Серверы ключа"
aria-expanded={tools === k.id}
onClick={() => setTools(tools === k.id ? null : k.id)}
><IcoSettings /></button>
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
</Fragment>
<tr key={k.id}>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
@@ -150,23 +110,13 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</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
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<span className="apikey-card-servers">Серверы: {serversText(k)}</span>
{tools === k.id && toolbox(k)}
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
+30 -94
View File
@@ -3,16 +3,14 @@
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
import { apiFetch, copyText, bytes, timeAgo, genLabel, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number }
interface Device {
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
@@ -49,7 +47,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const list: MasterKey[] = d.keys ?? [];
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
const all = await Promise.all(list.filter(k => !k.deleting).map(k =>
const all = await Promise.all(list.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
@@ -73,17 +71,10 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, 'Мастер-ключ создан');
}, [label, limit, nodes, token, act]);
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
const remove = useCallback(async (k: MasterKey) => {
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
try {
const r = await apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token);
const pending: number[] = r.pending ?? [];
showMsg(pending.length ? `«${k.label}» отключён, ждём подтверждения: ${serverNames(pending)}` : 'Удалён: ' + k.label);
await load();
} catch (e) { showMsg(errText(e)); }
}, [token, load, showMsg, serverNames]);
await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label);
}, [token, act]);
const fetchLink = useCallback(async (k: MasterKey) => {
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
@@ -103,25 +94,6 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
catch (e) { showMsg(errText(e)); }
}, [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 n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
});
@@ -129,20 +101,6 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
useEffect(() => { if (token) load(); }, [token, load]);
// Пока какой-то ключ ждёт серверы, время от времени проверяем, не подтвердили ли они.
const waiting = keys.some(k => k.deleting);
useEffect(() => {
if (!waiting) return;
const t = setInterval(load, 15_000);
return () => clearInterval(t);
}, [waiting, load]);
const deletingChip = (k: MasterKey) => (
<span className="chip chip--offline" title="Ключ уже не работает; строка исчезнет, когда серверы подтвердят, что сняли его устройства">
<span className="chip-dot" />Удаляется — ждёт: {serverNames(k.deleting ?? [])}
</span>
);
const actions = (k: MasterKey) => (
<div className="actions">
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
@@ -157,19 +115,6 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
{/* Слева — настройки самого ключа */}
<section className="mk-box">
<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-text">
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
@@ -178,7 +123,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<div className="mk-setting-ctl">
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
<button className="btn btn--tonal" onClick={() => saveLimit(k)}>
<button className="btn btn--tonal" onClick={() =>
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: Number(limitEdit) }), 'Лимит сохранён')}>
Сохранить
</button>
</div>
@@ -213,9 +159,27 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
<section className="mk-box">
<h4 className="mk-box-title">Серверы ключа</h4>
<ServerPicker nodes={nodes} selected={k.servers} onChange={(next, n, added) =>
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />
{nodes.map(n => {
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 }),
on ? `«${h?.server || n.name}» убран из ключа` : `«${h?.server || n.name}» добавлен в ключ`);
}} />
<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>
</section>
</div>
@@ -252,17 +216,6 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</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) => (
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
@@ -324,13 +277,6 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
{keys.length === 0 ? (
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
) : keys.map(k => {
if (k.deleting) return (
<tr key={k.id} className="mk-tr-master mk-last">
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
<td className="td-mono">{uuidCell(k)}</td>
<td colSpan={7}>{deletingChip(k)}</td>
</tr>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const open = !folded.has(k.id);
@@ -338,8 +284,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<Fragment key={k.id}>
<tr className="mk-tr-master">
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
<td className="td-mono">{uuidCell(k)}</td>
<td colSpan={5}></td>
<td colSpan={6}></td>
<td>{meter(k, list.length)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td>
</tr>
@@ -379,15 +324,6 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
<ul className="mk-tree">
{keys.map(k => {
if (k.deleting) return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
<IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
{deletingChip(k)}
</div>
</li>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const isFolded = folded.has(k.id);
@@ -396,7 +332,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<div className="mk-master">
{caret(k)}
<IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
<span className="mk-master-label">{k.label}</span>
{meter(k, list.length)}
{actions(k)}
</div>
+18 -17
View File
@@ -26,19 +26,6 @@ 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:hover td { background: var(--surface-container); }
.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-tr-master .mk-name { color: var(--on-surface); }
.mk-tr-master .mk-name svg { color: var(--primary); }
@@ -124,10 +111,6 @@ 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-hint { font-size: 12px; color: var(--neutral-text); }
.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-limit-input { height: 36px; width: 80px !important; text-align: center; }
@@ -166,6 +149,24 @@ LICENSE file in the root directory of this source tree.
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
/* серверы, отдаваемые по ключу (заглушка) */
.mk-server {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container);
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
color: var(--on-surface);
}
.mk-server input { accent-color: var(--primary); margin: 0; }
.mk-server-name { font-weight: 500; }
.mk-server-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); }
.mk-server-ip { font-family: var(--mono); margin-left: auto; }
.mk-server { 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%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
+158 -372
View File
@@ -16,14 +16,6 @@
// хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить,
// а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться
// (сразу при действии, при подключении ноды и раз в минуту).
//
// Каждый проход заканчивается сведением пиров панели (PUT /api/peers/sync): awg-ctrl
// получает полный список ключей, которые этой панели нужны на сервере, и снимает из её
// области всё остальное. Это страховка от пиров, чья судьба потерялась (ответ ноды не
// дошёл, устройство удалили посреди прохода). Область — id панели в owner пира
// («<panel>/m1/d2»): у ноды может быть своя панель на том же awg-ctrl, чужих не трогаем.
// Удаляем только по такому списку, никогда по времени: панель не на связи — ничего не
// происходит, неактивные устройства никто не снимает.
import crypto from "crypto";
import fs from "fs";
import http from "http";
@@ -46,12 +38,10 @@ export interface Servers {
ids(): number[];
defaultId(): number;
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 {
id: number; uuid: string; label: string; secret: Buffer; device_limit: number;
id: number; label: string; secret: Buffer; device_limit: number;
server_id: number; created_at: number; revoked_at: number | null;
}
interface DeviceRow {
@@ -79,17 +69,13 @@ const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения
const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url");
// ── Простой лимитер по IP ──────────────────────────────────────────────────
// onLimited зовётся один раз за окно — на первом отказе, чтобы лог не забивало.
function makeLimiter(max: number, windowMs: number, onLimited?: (ip: string) => void) {
function makeLimiter(max: number, windowMs: number) {
const hits = new Map<string, { n: number; reset: number }>();
return (ip: string): boolean => {
const now = Date.now();
const e = hits.get(ip);
if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; }
if (e.n >= max) {
if (e.n === max) { e.n++; onLimited?.(ip); }
return false;
}
if (e.n >= max) return false;
e.n++;
return true;
};
@@ -186,25 +172,11 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
profile TEXT 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"))
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");
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
@@ -213,39 +185,20 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
uidb.exec(`
INSERT OR IGNORE INTO master_servers (master_id, server_id)
SELECT id, server_id FROM master_keys
WHERE revoked_at IS NULL AND id NOT IN (SELECT master_id FROM master_servers);
WHERE id NOT IN (SELECT master_id FROM master_servers);
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
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 = {
// И отозванные: удалённый ключ висит в списке, пока все его серверы не подтвердят удаление.
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"),
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE revoked_at IS NULL ORDER BY id"),
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
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"),
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 = ?"),
masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? 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 = ?"),
masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE 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 (?, ?)"),
serverDel: uidb.prepare<[number, number]>("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?"),
@@ -275,16 +228,6 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
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 (?, ?)"),
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 = ?"),
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"),
@@ -347,7 +290,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 })[]) {
// null — не вышло, попробуем в следующий проход; "gone" — связь пропала, проход прекращаем.
const add = async (): Promise<Peer | null | "gone"> => {
const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `${panelId}/m${l.master_id}/d${l.device_id}` });
const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `m${l.master_id}/d${l.device_id}` });
if (hasCtrlOk(r.status)) return r.data;
if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся
return gone(r.status) ? "gone" : null;
@@ -378,33 +321,6 @@ 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);
}
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();
}
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
@@ -471,69 +387,49 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
verify: (req, _res, buf) => { (req as any).rawBody = buf; },
}));
const limited = (what: string) => (ip: string) => console.warn(`sub: 429 ${what} ip=${ip} — лимит исчерпан на 15 минут`);
const registerLimit = makeLimiter(30, 15 * 60 * 1000, limited("register"));
const failLimit = makeLimiter(30, 15 * 60 * 1000, limited("неудачные запросы"));
const registerLimit = makeLimiter(30, 15 * 60 * 1000);
const failLimit = makeLimiter(30, 15 * 60 * 1000);
const seenSigs = new Map<string, number>(); // подпись → когда забыть
const ipOf = (req: Request) => req.socket.remoteAddress ?? "unknown";
// Ответ подписан и несёт эхо запроса: device (X-Sen-Device) и ts (X-Sen-Ts). Клиент сверяет
// их со своим запросом — иначе подсунуть ему можно было бы чужой подписанный ответ (чужой
// 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));
function send(res: Response, status: number, payload: unknown) {
res.status(status).json(signResponse(payload, signKey as crypto.KeyObject));
}
// Проверка подписи запроса + защита от повторов. auth_pub — 32 байта.
// null — всё верно, иначе причина отказа (для лога).
function signFailure(req: Request, authPub: Buffer): string | null {
function checkSigned(req: Request, authPub: Buffer): boolean {
const ts = Number(req.headers["x-sen-ts"]);
const sig = String(req.headers["x-sen-sig"] ?? "");
if (!Number.isInteger(ts) || !sig) return "no_signature";
if (Math.abs(ts - now()) > TS_WINDOW) return `ts_window (часы клиента расходятся на ${ts - now()} с)`;
if (!Number.isInteger(ts) || Math.abs(ts - now()) > TS_WINDOW || !sig) return false;
const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0);
if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return "bad_signature";
if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return false;
const t = Date.now();
for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k);
if (seenSigs.has(sig)) return "replay";
if (seenSigs.has(sig)) return false;
seenSigs.set(sig, t + 2 * TS_WINDOW * 1000);
return null;
return true;
}
interface DevReq extends Request { device?: DeviceRow }
function deviceAuth(req: Request, res: Response, next: NextFunction) {
const id = Number(req.headers["x-sen-device"]);
const dev = Number.isSafeInteger(id) && id > 0 ? q.deviceById.get(id) as DeviceRow | undefined : undefined;
// Устройства нет — его удалили (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) {
const dev = Number.isInteger(id) ? q.deviceById.get(id) as DeviceRow | undefined : undefined;
if (dev && checkSigned(req, Buffer.from(dev.auth_pub, "base64url"))) {
(req as DevReq).device = dev;
next();
return;
}
// Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает.
const ip = ipOf(req);
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" });
if (!failLimit(req.socket.remoteAddress ?? "unknown")) { send(res, 429, { error: "rate_limited" }); return; }
send(res, 401, { error: "unauthorized" });
}
const wrap = (fn: (req: Request, res: Response) => Promise<void>) =>
(req: Request, res: Response) => { fn(req, res).catch(() => { send(req, res, 502, { error: "unavailable" }); }); };
(req: Request, res: Response) => { fn(req, res).catch(() => { send(res, 502, { error: "unavailable" }); }); };
sub.post("/sub/v1/register", wrap(async (req, res) => {
const ip = ipOf(req);
if (!registerLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
const ip = req.socket.remoteAddress ?? "unknown";
if (!registerLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; }
const b = (req.body ?? {}) as Record<string, unknown>;
const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null;
@@ -544,21 +440,16 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : "";
const platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : "";
const version = str(b.version, CLIENT_VERSION) ?? "";
if (!secretB || !deviceId || !pubKey || !authPubS) { send(req, res, 400, { error: "bad_request" }); return; }
if (!secretB || !deviceId || !pubKey || !authPubS) { send(res, 400, { error: "bad_request" }); return; }
// Регистрация подписана ключом auth_pub из самого тела — доказательство владения.
const authPub = Buffer.from(authPubS, "base64url");
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;
}
if (authPub.length !== 32 || !checkSigned(req, authPub)) { send(res, 401, { error: "unauthorized" }); return; }
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
if (!master) { send(res, 404, { error: "not_found" }); return; }
const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id));
if (serverIds.length === 0) { send(req, res, 502, { error: "unavailable" }); return; }
if (serverIds.length === 0) { send(res, 502, { error: "unavailable" }); return; }
let deviceRowId = 0;
let touched: number[] = [];
@@ -572,8 +463,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
for (const sid of serverIds) q.linkAdd.run(deviceRowId, sid);
})();
} catch (e: any) {
if (e?.code === "limit") { send(req, res, 403, { error: "device_limit" }); return; }
send(req, res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; }
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
}
await reconcileAll([...touched, ...serverIds]);
@@ -582,13 +473,13 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
if (cfg.servers.length === 0) {
// Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже.
await reconcileAll(forgetDevice(dev));
send(req, res, 502, { error: "unavailable" });
send(res, 502, { error: "unavailable" });
return;
}
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
// без отдельного запроса списка.
send(req, res, 201, {
device: dev.id, config: cfg, key_uuid: master.uuid,
send(res, 201, {
device: dev.id, config: cfg,
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
});
}));
@@ -597,15 +488,15 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register,
// а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать.
sub.post("/sub/v1/peek", wrap(async (req, res) => {
const ip = ipOf(req);
const ip = req.socket.remoteAddress ?? "unknown";
const secretB = typeof (req.body as any)?.sub === "string" && /^[A-Za-z0-9_-]{22}$/.test((req.body as any).sub) ? (req.body as any).sub as string : null;
const master = secretB ? q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined : undefined;
if (!master) {
if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
send(req, res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
if (!failLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; }
send(res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
return;
}
send(req, res, 200, { key_uuid: master.uuid, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
send(res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
}));
sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
@@ -614,7 +505,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
const v = String(req.headers["x-sen-version"] ?? "");
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
send(req, res, 200, { config: await readyConfig(dev, req) });
send(res, 200, { config: await readyConfig(dev, req) });
}));
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
@@ -622,10 +513,9 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
const master = q.masterById.get(dev.master_id) as MasterRow | undefined;
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
send(req, res, 200, {
if (!master) { send(res, 404, { error: "not_found" }); return; }
send(res, 200, {
name: master.label,
key_uuid: master.uuid,
device_limit: master.device_limit,
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
@@ -639,10 +529,10 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key;
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(req, res, 400, { error: "bad_request" }); return; }
if (pub === dev.pub_key) { send(req, res, 200, { config: await readyConfig(dev, req) }); return; }
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(res, 400, { error: "bad_request" }); return; }
if (pub === dev.pub_key) { send(res, 200, { config: await readyConfig(dev, req) }); return; }
try { q.devicePub.run(pub, 0, dev.id); }
catch { send(req, res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
catch { send(res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id);
await reconcileAll(ids);
@@ -650,117 +540,79 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
if (cfg.servers.length === 0) {
q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id);
await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить
send(req, res, 502, { error: "unavailable" });
send(res, 502, { error: "unavailable" });
return;
}
send(req, res, 200, { config: cfg });
send(res, 200, { config: cfg });
}));
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
await reconcileAll(forgetDevice(dev));
send(req, res, 200, { ok: true });
send(res, 200, { ok: true });
}));
sub.use((req, res) => { send(req, res, 404, { error: "not_found" }); });
sub.use((_req, res) => { send(res, 404, { error: "not_found" }); });
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
sub.use((_err: unknown, req: Request, res: Response, _next: NextFunction) => { send(req, res, 400, { error: "bad_request" }); });
sub.use((_err: unknown, _req: Request, res: Response, _next: NextFunction) => { send(res, 400, { error: "bad_request" }); });
// ── Мастер-ключи: общая логика панели и внешнего API ───────────────────
// Панель (/ui/masterkeys, /ui/devices, за JWT) видит всё. Внешний API (/api/v1/masterkeys,
// ключ awgk_) — только ключи, чьи серверы целиком входят в набор серверов API-ключа:
// иначе он мог бы удалить или перенастроить раздачу на сервере, которого не видит.
// 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);
// ── Админ-роуты панели (монтируются в server.ts за JWT) ─────────────────
const aw = (fn: (req: Request, res: Response) => Promise<void>) =>
(req: Request, res: Response) => { fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); }); };
const idOf = (req: Request) => Number(req.params.id);
const LABEL = /^[^\r\n<>]{1,40}$/;
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
const visible = (masterId: number, scope: Scope) =>
scope === null || serverIdsOf(masterId).every(id => scope.includes(id));
// Живой (не удаляемый) ключ в пределах области; чужой — как несуществующий.
function masterFor(id: number, scope: Scope): MasterRow {
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-ключа");
// Набор серверов из тела запроса: непустой, без повторов, только существующие.
function parseServers(v: unknown): number[] | string {
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
if (v.length > MAX_SERVERS) return `Не больше ${MAX_SERVERS} серверов`;
const ids = [...new Set(v)];
if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) return "Такого сервера нет";
return (ids as number[]).sort((a, b) => a - b);
}
// deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
function masterOut(m: MasterRow & { devices: number }) {
return {
id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit,
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 });
const masterKeys = Router();
const listMasters = (scope: Scope) =>
(q.masters.all() as (MasterRow & { devices: number })[]).filter(m => visible(m.id, scope)).map(masterOut);
masterKeys.get("/", (_req, res) => {
res.json({
enabled: signKey !== null,
tls: tlsPin !== null,
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
id: m.id, label: m.label, device_limit: m.device_limit,
devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at,
})),
});
});
// Серверы по умолчанию: у панели — сервер по умолчанию, у 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 символов, без переводов строк и <>");
masterKeys.post("/", (req, res) => {
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown };
if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; }
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
if (!limitOk(limit)) fail(400, "Лимит устройств: от 1 до 100");
let ids: number[];
try { ids = parseServers(wanted ?? scope ?? [servers.defaultId()], scope); }
catch (e) { throw wanted === undefined && scope === null ? new ApiError(400, "Сначала добавьте сервер") : e; }
if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
const ids = parseServers(wanted === undefined ? [servers.defaultId()] : wanted);
if (typeof ids === "string") { res.status(400).json({ error: wanted === undefined ? "Сначала добавьте сервер" : ids }); return; }
let id = 0;
uidb.transaction(() => {
id = Number(q.masterInsert.run(crypto.randomUUID(), label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
id = Number(q.masterInsert.run(label, crypto.randomBytes(16), limit, ids[0], now()).lastInsertRowid);
for (const sid of ids) q.serverAdd.run(id, sid);
})();
return masterOutById(id);
}
res.status(201).json({ id, label, device_limit: limit, devices: 0, servers: ids });
});
async function updateMaster(m: MasterRow, body: unknown, scope: Scope) {
const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown };
masterKeys.patch("/:id", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
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;
if (typeof nl !== "string" || !LABEL.test(nl)) fail(400, "Неверная метка");
if (!limitOk(nd)) fail(400, "Лимит устройств: от 1 до 100");
if (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; }
if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
let ids = serverIdsOf(m.id);
const affected: number[] = [];
if (wanted !== undefined) {
const next = parseServers(wanted, scope);
const next = parseServers(wanted);
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 devices = q.devicesOf.all(m.id) as DeviceRow[];
uidb.transaction(() => {
@@ -780,60 +632,54 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
affected.push(...added, ...removed);
ids = next;
}
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);
}
}
q.masterUpdate.run(nl, nd, m.id);
await reconcileAll(affected);
return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids, unbound };
}
res.json({ id: m.id, label: nl, device_limit: nd, servers: ids });
}));
// Новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
const rotateMaster = (m: MasterRow) => { q.masterSecret.run(crypto.randomBytes(16), m.id); };
const rekeyMaster = (m: MasterRow) => { q.deviceFlag.run(-1, m.id); };
masterKeys.post("/:id/rotate", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
q.masterSecret.run(crypto.randomBytes(16), m.id); // устройства продолжают работать: у них auth_pub
res.json({ id: m.id });
});
// Ключ сразу перестаёт работать (ссылка, регистрация, устройства), но строка остаётся
// отозванной, пока каждый его сервер не подтвердит, что снял пиров, — иначе панель
// сказала бы «удалён», пока на офлайн-ноде устройства ещё подключаются.
async function deleteMaster(m: MasterRow) {
const affected = serverIdsOf(m.id);
uidb.transaction(() => {
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
q.masterRevoke.run(now(), m.id);
})();
masterKeys.post("/:id/rekey", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
q.deviceFlag.run(-1, m.id);
res.json({ id: m.id });
});
masterKeys.delete("/:id", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const affected: number[] = [];
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
q.masterDelete.run(m.id); // master_servers уйдут каскадом
await reconcileAll(affected);
return { success: true, id: m.id, pending: serverIdsOf(m.id) };
}
res.json({ success: true, id: m.id });
}));
// Выдать ссылку можно, только если подписка настроена и известен адрес панели.
function linkProblem(req: Request): string | null {
if (!signKey || !signPub || !SUB_PORT) return "Подписка не настроена на сервере";
if (!subHost(req)) return "Не удалось определить адрес панели — задайте SUB_HOST";
return null;
}
function linkOf(m: MasterRow, req: Request) {
const problem = linkProblem(req);
if (problem) fail(503, problem);
masterKeys.get("/:id/link", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
if (!signKey || !signPub || !SUB_PORT) { res.status(503).json({ error: "Подписка не настроена на сервере" }); return; }
const host = subHost(req);
if (!host) { res.status(503).json({ error: "Не удалось определить адрес панели — задайте SUB_HOST" }); return; }
const link = encodeSenLink({
tls: tlsPin !== null,
addrs: [{ host: subHost(req), port: SUB_PORT }],
secret: m.secret, signPub: signPub as Buffer, tlsPin: tlsPin ?? undefined, name: m.label,
addrs: [{ host, port: SUB_PORT }],
secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label,
});
return { link, tls: tlsPin !== null };
}
res.json({ link, tls: tlsPin !== null });
});
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
async function masterDevices(m: MasterRow) {
masterKeys.get("/:id/devices", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
const r = await servers.ctrlFor(id)("GET", "/api/peers");
@@ -846,109 +692,49 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
});
}
}));
return (q.devicesOf.all(m.id) as DeviceRow[]).map(d => {
const links = q.linksOf.all(d.id) as LinkRow[];
const s = stats.get(d.pub_key);
return {
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
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,
};
res.json({
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => {
const links = q.linksOf.all(d.id) as LinkRow[];
const s = stats.get(d.pub_key);
return {
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
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,
};
}),
});
}
async function deleteDevice(d: DeviceRow) {
await reconcileAll(forgetDevice(d));
return { success: true, id: d.id };
}
const rekeyDevice = (d: DeviceRow) => { q.deviceFlag.run(d.id, -1); };
// Новый PSK на каждом сервере; недоступные получат его, когда появятся.
async function rotatePsk(d: DeviceRow) {
q.linkPskDue.run(d.id);
await reconcileAll((q.linksOf.all(d.id) as LinkRow[]).map(l => l.server_id));
return { id: d.id, pending: (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length };
}
// ── Роуты панели (монтируются в 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));
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));
res.json({ success: true, id: d.id });
}));
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;
}
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 на каждом сервере; недоступные получат его, когда появятся.
devices.post("/:id/psk", aw(async (req, res) => {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
q.linkPskDue.run(d.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;
res.json({ id: d.id, pending });
}));
// ── Листенер и фоновое сведение ────────────────────────────────────────
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() {
// Сводим и без подписки: у ключей могут остаться хвосты на удаление.
setInterval(() => {
@@ -963,5 +749,5 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
});
}
return { masterKeys, devices, apiRouter, start };
return { masterKeys, devices, start };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.3.4",
"version": "0.3.2",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+1 -140
View File
@@ -383,150 +383,12 @@ async function joinCore(link?: string, flag?: string) {
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
writeFileSync(envFile, content, { mode: 0o600 });
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
const dropPeers = !keepPanel && process.env.UI_PASS ? await askDropPanelPeers() : false;
if (!keepPanel) await stop("ui");
if (dropPeers) await dropArea(localPanelId()!);
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
restartFresh();
}
// ── Пиры подписки (sen://) на локальном awg-ctrl ──────────────────────────────
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели.
// У ноды со своей панелью на одном awg-ctrl две области (своя и core). Пиры без области
// («m1/d2») выданы до областей. Снимает их awg-ctrl по полному списку от панели
// (PUT /api/peers/sync); пустой список = снять всю область. Здесь — ручной рычаг для
// оператора: панель выключена или core пропал насовсем. По времени ничего не удаляется.
const PANEL_ID_FILE = path.join(ROOT, "awg-ui", "panel.id");
const AREA_RE = /^[a-z0-9]{8,16}$/;
const LEGACY_OWNER = /^m\d+\/d\d+$/;
function localPanelId(): string | null {
try { const id = readFileSync(PANEL_ID_FILE, "utf8").trim(); return AREA_RE.test(id) ? id : null; }
catch { return null; }
}
// Тот же короткоживущий токен, что awg-ui и awg-agent шлют awg-ctrl (Ed25519, 60 с).
function mintInternalToken(): string {
const key = crypto.createPrivateKey(readFileSync(SERVICES.ui.env.INTERNAL_AUTH_KEY_FILE));
const t = Math.floor(Date.now() / 1000);
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
const p = Buffer.from(JSON.stringify({ iss: "awg-cli", iat: t, exp: t + 60 })).toString("base64url");
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), key).toString("base64url")}`;
}
function ctrlCall(method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const req = http.request({
host: "127.0.0.1", port: Number(SERVICES.awgctrl.env.PORT), method, path: urlPath, timeout: 30_000,
headers: {
Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json",
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
},
}, res => {
let raw = "";
res.on("data", c => { raw += c; });
res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, data: null }); } });
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("timeout")));
req.end(payload);
});
}
interface CtrlPeer { pub_key: string; ip: string; owner: string; online: boolean }
async function listPeers(): Promise<CtrlPeer[]> {
const r = await ctrlCall("GET", "/api/peers");
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
return r.data.peers as CtrlPeer[];
}
// Область пира: id панели, «legacy» (до областей) или null (чужой формат — не наш).
function areaOf(owner: string): string | null {
const head = owner.split("/")[0];
if (owner.includes("/") && AREA_RE.test(head)) return head;
return LEGACY_OWNER.test(owner) ? "legacy" : null;
}
const peersWord = (n: number) =>
n % 10 === 1 && n % 100 !== 11 ? "пир" : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? "пира" : "пиров";
async function askDropPanelPeers(): Promise<boolean> {
const own = localPanelId();
if (!own) return false;
let n: number;
try { n = (await listPeers()).filter(p => areaOf(p.owner) === own).length; }
catch (e) { console.warn(` ${dim(`Не удалось посмотреть пиров подписки: ${(e as Error).message}`)}`); return false; }
if (n === 0) return false;
const preset = (process.env.NODE_PEERS ?? "").toLowerCase();
if (preset === "keep" || preset === "drop") return preset === "drop";
console.log(`\n На этой панели ${n} ${peersWord(n)} подписки (устройства мастер-ключей). Без панели ими некому управлять:`);
console.log(` 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin`);
console.log(` 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)`);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const a = await new Promise<string>(r => rl.question(" Выбор [1]: ", r));
rl.close();
return a.trim() === "2";
}
// Снять всю область: для панели — пустым списком через sync, для legacy — по одному.
async function dropArea(area: string): Promise<void> {
try {
if (area === "legacy") {
const legacy = (await listPeers()).filter(p => areaOf(p.owner) === "legacy");
for (const p of legacy) await ctrlCall("DELETE", `/api/peers/${Buffer.from(p.pub_key, "base64").toString("base64url")}`);
console.log(` ${green("✓")} Снято пиров без области: ${legacy.length}`);
return;
}
const r = await ctrlCall("PUT", "/api/peers/sync", { owner: area, keep: [] });
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
console.log(` ${green("✓")} Снято пиров области ${area}: ${r.data.removed}`);
} catch (e) {
console.error(` Не удалось снять пиров: ${(e as Error).message}`);
}
}
async function peersCmd(sub?: string, arg?: string) {
const own = localPanelId();
let peers: CtrlPeer[];
try { peers = await listPeers(); }
catch (e) { console.error(` awg-ctrl недоступен: ${(e as Error).message}`); process.exit(1); }
const areas = new Map<string, CtrlPeer[]>();
for (const p of peers) { const a = areaOf(p.owner); if (a) areas.set(a, [...(areas.get(a) ?? []), p]); }
if (sub === undefined) {
if (!areas.size) { console.log(" Пиров подписки (sen://) на этом сервере нет"); return; }
console.log("\n Пиры подписки (sen://) на этом сервере:\n");
for (const [a, list] of areas) {
const mark = a === own ? "эта панель" : a === "legacy" ? "без области (выданы до областей)" : "";
console.log(` ${bold(a.padEnd(16))} ${String(list.length).padStart(4)} ${peersWord(list.length).padEnd(5)}` +
` онлайн ${list.filter(p => p.online).length} ${dim(mark)}`);
}
console.log(`\n ${dim("Снять область целиком: awg-ctrl peers drop <область>")}`);
return;
}
if (sub !== "drop" || !arg || !(arg === "legacy" || AREA_RE.test(arg))) {
console.error(" Usage: awg-ctrl peers [drop <область|legacy>]");
process.exit(1);
}
const n = areas.get(arg)?.length ?? 0;
if (!n) { console.log(` В области ${arg} пиров нет`); return; }
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
if (arg === own && isRunning("ui")) {
console.error(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней");
process.exit(1);
}
console.log(` ${dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")}`);
console.log(` ${dim("сперва удалите сервер в ней (или мастер-ключи).")}`);
if (!await confirmYes(` Снять ${n} ${peersWord(n)} области ${arg}? Их устройства потеряют доступ к этому серверу.`)) return;
await dropArea(arg);
}
async function unjoinCore() {
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
await stop("agent");
@@ -640,7 +502,7 @@ async function main() {
return;
}
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]>";
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin>";
const [,,, sub, arg] = process.argv;
@@ -656,7 +518,6 @@ async function main() {
break;
case "join": await joinCore(sub, arg); break;
case "unjoin": await unjoinCore(); break;
case "peers": await peersCmd(sub, arg); break;
default: console.log(USAGE); process.exit(1);
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места.
VERSION="0.3.4"
VERSION="0.3.2"
CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт — задаётся окружением, в коде