19 Commits
Author SHA1 Message Date
maeneko 7fbff82e76 Bump to Beta 0.3.4
ci / check (push) Successful in 6m20s
release / release (push) Successful in 3m43s
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.

The panel shows each server's Forgetting version; master keys get a
stable uuid; lowering a key's device limit unbinds the newest devices.
2026-09-28 14:12:27 +03:00
maeneko 565b2759ed Unbind the newest devices when a master key's limit drops
Lowering device_limit below the number of bound devices now forgets the
extra ones right away, newest first, so the devices that took their
slots first keep them. Their peers are removed and they get 410 like a
revoke from the panel. PATCH returns the unbound ids; the panel names
the devices that will be dropped and asks before saving.
2026-09-28 14:12:27 +03:00
maeneko f0f2f9848a Give master keys a stable uuid
- master_keys.uuid (UUID v4, unique index): new keys get one on create,
  existing keys once on start. It survives rotate (new secret) and
  relabel, so it identifies the key itself rather than its link.
- /ui/masterkeys* and /api/v1/masterkeys* return uuid, and :id in those
  paths accepts the numeric id or the uuid.
- /sub/v1 register, peek and devices return key_uuid, so a client can
  recognise the same key behind a reissued link. It is not in the sen://
  link (that would need a new format) and not in config, so rev and
  existing clients are unaffected - checked against a 0.3.1 ui.db.
- Panel: the uuid sits in the key's row like a device ID (full value in
  the tooltip, click copies) and in the key settings with a copy button;
  on mobile it is shown under the key's name.
- README: uuid in the master key API.
2026-09-27 18:04:05 +03:00
maeneko 5c3bf21918 Show each server's Forgetting version in the panel
- awg-agent sends its install's VERSION (from the CLI's env, else the
  project's .env) in the auth frame; the hub keeps it in nodes.version
  (added by migration), so an offline node still shows the last one.
- GET /ui/nodes returns version per server (0 is the panel's own) and the
  panel's version at the top.
- ServerBar: a vX.Y.Z chip next to the AWG generation. A node older or
  newer than the panel, or one that sends no version (agent before
  0.3.4, shown as v?.?.?), gets a warm chip with an "update it" tooltip.
- App.css: tertiary-container tokens from design.md (light) and a warm
  dark pair; they did not exist yet.
- Mock server: panel 0.3.3, Frankfurt behind on 0.3.2, Amsterdam with no
  version.
2026-09-27 18:03:57 +03:00
maeneko eb0ec2878c Bump to Beta 0.3.3
ci / check (push) Successful in 7m12s
release / release (push) Successful in 3m34s
All seven places the version is written down: .env, the copy in
install.sh, the four package.json files and the /ui/brand example in the
README.

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

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

- awg-ctrl: PUT /api/peers/sync takes a panel's full list of wanted keys
  and removes every other peer of that panel's area. Removal is driven
  only by such an explicit list, never by time or inactivity: a panel
  that is offline sends nothing, so nothing is deleted. Registered before
  PUT /api/peers/:pub, with its own 512 kB body limit.
- Every panel has a stable id (ui.db, mirrored to awg-ui/panel.id) and
  adds peers as "<panel>/m<key>/d<device>", so two panels on one awg-ctrl
  never touch each other's peers. Legacy "m1/d2" peers are claimed only
  when their key is in the panel's list.
- Each reconcile pass ends with the sync, built from ui.db at that moment.
  Its reply also self-heals: a device whose key is missing on a server
  (a rekey rolled back after a lost reply) is added again.
- Deleting a master key stops it at once but keeps it listed as
  "Удаляется — ждёт: …" until every server confirms, instead of saying
  "deleted" while an offline node still lets the devices in.
- Deleting a node reconciles it first; if it is offline or has not
  confirmed a deleted key, it takes ?force=1 and a warning. Tombstones
  are no longer dropped silently.
- /sub/v1: an unknown device gets a signed 410 revoked that does not count
  towards the failure limit (dead clients behind one node NAT used to
  lock everyone out), every response echoes the request's ts and device,
  and 401 reasons and the first 429 per window are logged. config and rev
  are unchanged, byte-identical on one server.
- CLI: `awg-ctrl peers [drop <area|legacy>]` lists and removes
  subscription peers by area; `join --no-panel` asks whether to keep or
  drop the panel's own peers (NODE_PEERS=keep|drop).
2026-09-27 15:30:53 +03:00
maeneko 3eede2f015 Bump to Beta 0.3.2
release / release (push) Successful in 3m35s
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 2054122110 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 129f29d6be 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 2f2f0e268a Bump to Beta 0.3.1
release / release (push) Successful in 3m18s
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 2e773deba2 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 aa31b7b8a2 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 789538756a 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 1278ad527d Add /sub/v1/devices and /sub/v1/peek, count the slots on register
release / release (push) Successful in 3m14s
- 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 6e010a0141 Bump to Beta 0.3.0
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 923bd87fe9 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
maeneko d5e00ddaf8 Bump to Beta 0.2.2
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.
2026-09-10 17:51:19 +03:00
maeneko 2b46d04af9 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.
2026-09-10 17:51:19 +03:00
31 changed files with 4831 additions and 622 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting BRAND=Forgetting
CHANNEL=Beta CHANNEL=Beta
VERSION=0.2.2 VERSION=0.3.4
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}" mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/" cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/" tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не # Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}" mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/" cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/" tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- name: Publish release - name: Publish release
+1
View File
@@ -4,6 +4,7 @@
**/node_modules/ **/node_modules/
**/dist/ **/dist/
cli/cli.env cli/cli.env
awg-ui/panel.id
logs/ logs/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
+53 -3
View File
@@ -83,6 +83,39 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить» `"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить»
ключ = удалить и создать заново. ключ = удалить и создать заново.
У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка;
`0` — сервер самой панели, остальные — подключённые ноды). Пользователи `vpn://`
заводятся на сервере ключа по умолчанию; другой сервер из набора выбирается
заголовком `X-Server-Id: <id>`, сервер вне набора → `404`.
#### Мастер-ключи `sen://`
Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое
устройство само регистрируется по ссылке и получает пира на всех серверах ключа,
его приватный ключ сервер не видит. API-ключ видит и меняет мастер-ключ, только
если **все** его серверы входят в набор API-ключа; остальные для него не
существуют (`404`).
```bash
curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
-H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \
-d '{"label":"Семья","device_limit":3}' | jq -r .link
```
| Метод | Что делает |
|---|---|
| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, uuid, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) |
| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`. `uuid` — постоянный идентификатор ключа: не меняется ни при перевыпуске ссылки, ни при правке метки. Везде, где в пути `:id`, можно передать и `uuid` |
| `GET /api/v1/masterkeys/:id` | Ключ, ссылка и устройства одним запросом: те же поля + `link`, `tls`, `device_list` (`devices` — счётчик) |
| `PATCH /api/v1/masterkeys/:id` | `{ label?, device_limit?, servers? }` — смена серверов сразу добавляет/снимает пиров устройств |
| `POST /api/v1/masterkeys/:id/rotate` | Новая ссылка `{ id, link, tls }`; старая не принимает новые устройства, подключённые работают дальше |
| `POST /api/v1/masterkeys/:id/rekey` | Все устройства сменят ключи WireGuard при следующем опросе (IP и PSK сохраняются) |
| `DELETE /api/v1/masterkeys/:id` | `{ success, id, pending }` — ключ и устройства отключаются сразу; `pending` — серверы (ноды не в сети), которые ещё не сняли пиров, до тех пор ключ в списке с `deleting` |
| `GET /api/v1/masterkeys/:id/devices` | `{ devices: [{ id, device_name, platform, version, online, lastHandshake, rx, tx, last_seen, servers_ok, servers_total, … }] }` — статистика по всем серверам ключа |
| `DELETE /api/v1/masterkeys/:id/devices/:device` | Отвязывает устройство, место освобождается |
| `POST /api/v1/masterkeys/:id/devices/:device/rekey` | Устройство сменит ключ при следующем опросе |
| `POST /api/v1/masterkeys/:id/devices/:device/psk` | Новый PSK: `{ id, pending }` — `pending` серверов ждут ноду |
| Код | Когда | | Код | Когда |
|---|---| |---|---|
| `401 {"error":"API key required"}` | заголовка нет или он не начинается с `awgk_` | | `401 {"error":"API key required"}` | заголовка нет или он не начинается с `awgk_` |
@@ -92,8 +125,9 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне
каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про
внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией. внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией.
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` и Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем
`DELETE /ui/apikeys/:id`. `servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
перевыпуская ключ, и `DELETE /ui/apikeys/:id`. Подробно — `awg-ui/API.md`.
### Авторизация панели ### Авторизация панели
@@ -115,7 +149,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ | | Метод | Ответ |
|---|---| |---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.2.2" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина | | `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
@@ -156,6 +190,22 @@ base64url-декодировать, отбросить первые 4 байта
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс 3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
поднимается обратно на 2.0. поднимается обратно на 2.0.
### Мастер-ключи
Те же операции, что в `/api/v1/masterkeys`, но без ограничения набором серверов.
| Метод | Что делает |
|---|---|
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`; `:id` в путях — id или `uuid` |
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` |
| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком |
| `POST /ui/masterkeys/:id/rotate`, `/rekey` | Новая ссылка / смена ключей у всех устройств |
| `DELETE /ui/masterkeys/:id` | `{ success, id, pending }` |
| `DELETE /ui/devices/:id` | Отвязать устройство |
| `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства |
## Управление ## Управление
```bash ```bash
+181
View File
@@ -0,0 +1,181 @@
// 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.
//
// Агент ноды. Сам открывает исходящее WSS-соединение к core (awg-ui), доказывает
// свою личность подписью Ed25519 и исполняет присланные запросы на локальном
// awg-ctrl (127.0.0.1). Входящих портов у ноды нет, awg-ctrl остаётся закрытым от сети.
// Протокол и формат join-строки — docs/node-protocol.md.
import crypto from "crypto";
import fs from "fs";
import path from "path";
import type { TLSSocket } from "tls";
import axios from "axios";
import WebSocket from "ws";
const CORE_HOST = process.env.CORE_HOST ?? "";
const CORE_PORT = Number(process.env.CORE_PORT) || 0;
const CORE_PIN = process.env.CORE_PIN ?? ""; // SHA-256 SPKI сертификата core, base64url
const NODE_ID = Number(process.env.NODE_ID) || 0;
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
const NODE_KEY_FILE = process.env.NODE_KEY_FILE ?? path.join(__dirname, "node.key");
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnezia/amneziawg/internal_auth_private.key";
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);
}
// ── Ключи ──────────────────────────────────────────────────────────────────
// Личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
function loadNodeKey(): crypto.KeyObject {
if (!fs.existsSync(NODE_KEY_FILE)) {
const { privateKey } = crypto.generateKeyPairSync("ed25519");
fs.writeFileSync(NODE_KEY_FILE, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
console.log("agent: создан ключ ноды " + NODE_KEY_FILE);
}
return crypto.createPrivateKey(fs.readFileSync(NODE_KEY_FILE));
}
const nodeKey = loadNodeKey();
const nodePubRaw = (() => {
const der = crypto.createPublicKey(nodeKey).export({ type: "spki", format: "der" }) as Buffer;
return der.subarray(der.length - 32).toString("base64url");
})();
let internalKey: crypto.KeyObject | null = null;
try { internalKey = crypto.createPrivateKey(fs.readFileSync(INTERNAL_AUTH_KEY_FILE)); }
catch { console.warn("agent: приватный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_KEY_FILE); }
// Тот же короткоживущий токен, что awg-ui шлёт своему awg-ctrl.
function mintInternalToken(): string {
if (!internalKey) throw new Error("no internal key");
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-agent", iat: t, exp: t + 60 })).toString("base64url");
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), internalKey).toString("base64url")}`;
}
// Дублируется в awg-ui/nodes.ts.
const authMsg = (nonce: string) => Buffer.from(`awg-node-v1\n${nonce}\n${NODE_ID}`);
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
const METHODS = new Set(["GET", "POST", "PUT", "DELETE"]);
// Агент — не открытый прокси: только API самого awg-ctrl.
function pathAllowed(p: unknown): p is string {
if (typeof p !== "string" || p.length > 2048 || !/^\/[\x21-\x7e]*$/.test(p)) return false;
try {
const u = new URL(p, "http://x");
if (u.origin !== "http://x" || u.pathname.split("/").some(s => s === ".." || s === ".")) return false;
return u.pathname === "/health" || u.pathname.startsWith("/api/") || u.pathname.startsWith("/awg/");
} catch { return false; }
}
async function execute(m: { method?: unknown; path?: unknown; body?: unknown }): Promise<{ status: number; data: unknown }> {
const method = typeof m.method === "string" ? m.method.toUpperCase() : "";
if (!METHODS.has(method) || !pathAllowed(m.path)) return { status: 400, data: { error: "Запрос не разрешён агентом" } };
try {
const r = await axios({
method, url: CTRL + m.path,
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
data: m.body, validateStatus: () => true, timeout: 110_000,
});
return { status: r.status, data: r.data };
} catch {
return { status: 502, data: { error: "awg-ctrl недоступен" } };
}
}
// ── Соединение с core ──────────────────────────────────────────────────────
function pinOf(sock: TLSSocket): string | null {
const raw = sock.getPeerCertificate(true)?.raw;
if (!raw) return null;
const spki = new crypto.X509Certificate(raw).publicKey.export({ type: "spki", format: "der" }) as Buffer;
return crypto.createHash("sha256").update(spki).digest("base64url");
}
// После первого успешного входа секрет больше не нужен — убираем его с диска.
function forgetJoinSecret() {
joinSecret = "";
delete process.env.JOIN_SECRET;
if (!CLI_ENV_FILE) return;
try {
const kept = fs.readFileSync(CLI_ENV_FILE, "utf8").split("\n").filter(l => !l.startsWith("JOIN_SECRET="));
fs.writeFileSync(CLI_ENV_FILE, kept.join("\n"));
} catch { /* не критично: секрет одноразовый и с истекающим сроком */ }
}
let backoff = 1000;
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
function connectOnce(): Promise<{ authFailed: boolean }> {
return new Promise(resolve => {
// Сертификат самоподписанный — цепочку не проверяем, доверие даёт pin (см. 'upgrade').
const ws = new WebSocket(`wss://${CORE_HOST}:${CORE_PORT}/node/v1`, {
rejectUnauthorized: false, handshakeTimeout: 10_000, maxPayload: 4 * 1024 * 1024,
});
let pinned = false, ready = false, deadTimer: NodeJS.Timeout | undefined;
const armDead = () => { clearTimeout(deadTimer); deadTimer = setTimeout(() => ws.terminate(), 60_000); };
// До этого момента ничего не отправляем и не читаем: секрет и подпись уходят только
// тому, чей сертификат совпал с pin из join-строки.
ws.on("upgrade", res => {
const got = pinOf(res.socket as TLSSocket);
if (got === CORE_PIN) { pinned = true; return; }
console.error("agent: сертификат core не совпал с pin — соединение отклонено");
ws.terminate();
});
ws.on("ping", armDead);
ws.on("message", async raw => {
if (!pinned) return;
let m: any;
try { m = JSON.parse(raw.toString()); } catch { ws.terminate(); return; }
if (m.t === "hello" && typeof m.nonce === "string") {
const auth: Record<string, unknown> = {
t: "auth", node_id: NODE_ID,
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();
if (joinSecret) { forgetJoinSecret(); console.log("agent: нода привязана к core"); }
console.log("agent: подключено к core");
} else if (m.t === "req" && ready && Number.isInteger(m.id)) {
const r = await execute(m);
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t: "res", id: m.id, ...r }));
}
});
ws.on("error", e => { if (!pinned || !ready) console.error("agent: " + (e as Error).message); });
ws.on("close", code => {
clearTimeout(deadTimer);
if (code === 4003) console.error("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)");
resolve({ authFailed: code === 4003 });
});
});
}
async function main() {
console.log(`agent: нода #${NODE_ID} → wss://${CORE_HOST}:${CORE_PORT}`);
for (;;) {
const { authFailed } = await connectOnce();
const wait = authFailed ? 60_000 : backoff;
backoff = Math.min(backoff * 2, 60_000);
await sleep(wait);
}
}
main();
+19
View File
@@ -0,0 +1,19 @@
{
"name": "awg-agent",
"version": "0.3.4",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
"check": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.7.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["index.ts"]
}
+229 -6
View File
@@ -87,6 +87,20 @@ db.exec(`
) )
`); `);
// Пиры устройств sen://-подписки. Отдельная таблица, users не трогаем: у этих
// пиров нет ни vpn_key, ни имени — приватный ключ живёт только на устройстве,
// сюда приходит лишь публичный. owner — непрозрачная метка от awg-ui
// («m<masterId>/d<deviceId>»), нужна только для комментария в conf и логов.
db.exec(`
CREATE TABLE IF NOT EXISTS peers (
pub_key TEXT PRIMARY KEY,
ip TEXT NOT NULL UNIQUE,
psk_key TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)
`);
const cfgStmts = { const cfgStmts = {
get: db.prepare<[string], { value: string }>("SELECT value FROM config WHERE key = ?"), get: db.prepare<[string], { value: string }>("SELECT value FROM config WHERE key = ?"),
set: db.prepare<[string, string]>("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"), set: db.prepare<[string, string]>("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"),
@@ -276,12 +290,28 @@ interface UserRow {
vpn_key_prev: string; vpn_key_prev: string;
} }
interface PeerRow {
pub_key: string;
ip: string;
psk_key: string;
owner: string;
created_at: number;
}
const stmts = { const stmts = {
peersAll: db.prepare<[], PeerRow>("SELECT * FROM peers ORDER BY created_at, rowid"),
peerGet: db.prepare<[string], PeerRow>("SELECT * FROM peers WHERE pub_key = ?"),
peerInsert: db.prepare<[string, string, string, string, number]>("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)"),
peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"),
peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"),
peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"),
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 = ?"), get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
all: db.prepare<[], UserRow>("SELECT * FROM users"), all: db.prepare<[], UserRow>("SELECT * FROM users"),
insert: db.prepare<[string, string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)"), insert: db.prepare<[string, string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)"),
delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"), delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"),
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"), ips: db.prepare<[], { ip: string }>("SELECT ip FROM users UNION SELECT ip FROM peers"),
// Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут // Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут
// смениться, если исходный ключ не удалось разобрать. // смениться, если исходный ключ не удалось разобрать.
reissue: db.prepare<[string, string, string, string, string]>( reissue: db.prepare<[string, string, string, string, string]>(
@@ -547,17 +577,22 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
function rebuildConf() { function rebuildConf() {
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[]; const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
const devices = stmts.peersAll.all();
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`); const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
if (!existsSync(confFile)) return; if (!existsSync(confFile)) return;
const conf = readFileSync(confFile, "utf8"); const conf = readFileSync(confFile, "utf8");
const iface = conf.split(/^\[Peer\]/m)[0].trimEnd(); const iface = conf.split(/^\[Peer\]/m)[0].trimEnd();
const peers = (users as any[]).map(u => // Без записей в peers вывод байт-в-байт прежний.
`\n# ${u.name}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}` const peers = [
...users.map(u => ({ label: u.name, ...u })),
...devices.map(d => ({ label: d.owner || "device", ...d })),
].map(u =>
`\n# ${u.label}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}`
).join("\n"); ).join("\n");
writeFileSync(confFile, iface + "\n" + peers + "\n"); writeFileSync(confFile, iface + "\n" + peers + "\n");
logger.info("conf rebuilt", { peers: users.length }); logger.info("conf rebuilt", { peers: users.length + devices.length });
} }
const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key"; const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key";
@@ -829,8 +864,135 @@ function removeUser(username: string) {
logger.info("user removed", { name: username }); logger.info("user removed", { name: username });
} }
// ── Пиры устройств (sen://) ────────────────────────────────────────────────
// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим
// публичным ключом». Приватного ключа устройства здесь нет и быть не может.
class HttpError extends Error {
constructor(public status: number, message: string) { super(message); }
}
// Публичный ключ WireGuard: base64 от 32 байт (44 символа, последний «=»).
function checkPubKey(k: unknown): string {
if (typeof k !== "string" || !/^[A-Za-z0-9+/]{43}=$/.test(k) || Buffer.from(k, "base64").length !== 32)
throw new HttpError(400, "Неверный публичный ключ");
return k;
}
// В пути base64url: «/» и «+» обычного base64 в URL ломают маршрут.
function pubKeyFromParam(p: string): string {
return checkPubKey(Buffer.from(p, "base64url").toString("base64"));
}
function assertPubKeyFree(pub: string) {
if (stmts.peerGet.get(pub) || stmts.userPubTaken.get(pub)!.n > 0 || pub === getServerPublicKey())
throw new HttpError(409, "Публичный ключ уже используется");
}
function addPeer(pubKey: string, owner: string): { ip: string; psk_key: string } {
checkPubKey(pubKey);
const psk = run("awg genpsk");
const ip = db.transaction(() => {
assertPubKeyFree(pubKey);
const ip = nextIp();
stmts.peerInsert.run(pubKey, ip, psk, owner, Math.floor(Date.now() / 1000));
return ip;
})();
const r = setPeer(pubKey, psk, ip);
if (r.status !== 0) {
stmts.peerDelete.run(pubKey);
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
}
rebuildConf();
logger.info("peer added", { owner, ip });
return { ip, psk_key: psk };
}
// Смена ключа устройства: IP и PSK остаются, меняется только pub_key.
function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: string } {
checkPubKey(newPub);
const old = stmts.peerGet.get(oldPub);
if (!old) throw new HttpError(404, "Пир не найден");
db.transaction(() => { assertPubKeyFree(newPub); stmts.peerRekey.run(newPub, oldPub); })();
const r = setPeer(newPub, old.psk_key, old.ip);
if (r.status !== 0) {
stmts.peerRekey.run(oldPub, newPub);
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
}
spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]);
rebuildConf();
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
return { ip: old.ip, psk_key: old.psk_key };
}
function rotatePeerPsk(pub: string): { ip: string; psk_key: string } {
const peer = stmts.peerGet.get(pub);
if (!peer) throw new HttpError(404, "Пир не найден");
const psk = run("awg genpsk");
const r = setPeer(pub, psk, peer.ip);
if (r.status !== 0) throw new Error(`awg set failed: ${r.stderr?.toString()}`);
stmts.peerPsk.run(psk, pub);
rebuildConf();
logger.info("peer psk rotated", { owner: peer.owner });
return { ip: peer.ip, psk_key: psk };
}
function removePeer(pub: string) {
const peer = stmts.peerGet.get(pub);
if (!peer) throw new HttpError(404, "Пир не найден");
stmts.peerDelete.run(pub);
spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
rebuildConf();
logger.info("peer removed", { owner: peer.owner });
}
// Сведение пиров одной панели: панель присылает полный список ключей, которые ей нужны
// на этом сервере, — всё остальное из её области («<panel>/…») снимается. Удаляем только
// по такому явному списку, никогда по времени или неактивности: панель не на связи —
// ничего не происходит. Пиры без области (owner «m1/d2», выданы до областей) панель
// забирает себе, только если их ключ есть в её списке; остальные не трогаем — чьи они,
// уже не разобрать.
const PANEL_ID = /^[a-z0-9]{8,16}$/;
const LEGACY_OWNER = /^m\d+\/d\d+$/;
const SYNC_MAX = 10_000;
function syncOwnedPeers(panel: string, keep: string[]): {
present: string[]; removed: number; legacy: { pub_key: string; owner: string; ip: string }[];
} {
const want = new Set(keep);
const prefix = panel + "/";
const removed: PeerRow[] = [];
let claimed = 0;
db.transaction(() => {
for (const p of stmts.peersAll.all()) {
if (p.owner.startsWith(prefix)) {
if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
claimed++;
}
}
})();
for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]);
if (removed.length || claimed) rebuildConf();
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
const all = stmts.peersAll.all();
return {
present: all.filter(p => p.owner.startsWith(prefix)).map(p => p.pub_key),
removed: removed.length,
legacy: all.filter(p => LEGACY_OWNER.test(p.owner)).map(p => ({ pub_key: p.pub_key, owner: p.owner, ip: p.ip })),
};
}
const app = express(); const app = express();
app.use(express.json({ limit: "1kb" })); // Сведение пиров несёт полный список ключей панели — ему лимит побольше, остальным хватает 1kb.
const SYNC_PATH = "/api/peers/sync";
const jsonSmall = express.json({ limit: "1kb" });
const jsonSync = express.json({ limit: "512kb" });
app.use((req, res, next) => (req.path === SYNC_PATH ? jsonSync : jsonSmall)(req, res, next));
function verifyInternalToken(token: string): boolean { function verifyInternalToken(token: string): boolean {
try { try {
@@ -863,7 +1025,10 @@ function validateName(req: Request, res: Response, next: NextFunction) {
function handler(fn: (req: Request, res: Response) => void | Promise<void>) { function handler(fn: (req: Request, res: Response) => void | Promise<void>) {
return async (req: Request, res: Response) => { return async (req: Request, res: Response) => {
try { await fn(req, res); } try { await fn(req, res); }
catch (e) { logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" }); } catch (e) {
if (e instanceof HttpError) { res.status(e.status).json({ error: e.message }); return; }
logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" });
}
}; };
} }
@@ -931,6 +1096,64 @@ app.delete("/api/users/:name", auth, validateName, handler((req, res) => {
res.json({ success: true, name: req.params.name }); res.json({ success: true, name: req.params.name });
})); }));
app.post("/api/peers", auth, handler((req, res) => {
const { pub_key, owner } = (req.body ?? {}) as { pub_key?: string; owner?: string };
if (typeof owner !== "string" || !/^[\w/.-]{0,64}$/.test(owner)) throw new HttpError(400, "Неверный owner");
res.status(201).json(addPeer(checkPubKey(pub_key), owner));
}));
app.get("/api/peers", auth, handler((_req, res) => {
const stats = getPeersData();
res.json({
peers: stmts.peersAll.all().map(p => ({
pub_key: p.pub_key, ip: p.ip, psk_key: p.psk_key, owner: p.owner,
online: stats[p.pub_key]?.online ?? false,
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
rx: stats[p.pub_key]?.rx ?? 0,
tx: stats[p.pub_key]?.tx ?? 0,
})),
});
}));
// Должен стоять ДО 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)));
}));
app.post("/api/peers/:pub/psk", auth, handler((req, res) => {
res.json(rotatePeerPsk(pubKeyFromParam(req.params.pub)));
}));
app.delete("/api/peers/:pub", auth, handler((req, res) => {
removePeer(pubKeyFromParam(req.params.pub));
res.json({ success: true });
}));
// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf;
// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf.
app.get("/api/profile", auth, handler((_req, res) => {
const awg: Record<string, string> = {};
for (const [k, v] of Object.entries(CONFIG.awgParams)) if (String(v) !== "") awg[k] = String(v);
res.json({
name: CONFIG.serverName,
endpoint: `${CONFIG.serverIp}:${CONFIG.serverPort}`,
server_pub: getServerPublicKey(),
gen: CONFIG.gen,
dns: [CONFIG.dns1, CONFIG.dns2],
keepalive: CONFIG.keepalive,
mtu: CONFIG.mtu,
awg,
});
}));
app.post("/awg/restart", auth, handler((_req, res) => { app.post("/awg/restart", auth, handler((_req, res) => {
restartAwg(); restartAwg();
res.json({ success: true }); res.json({ success: true });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.2.2", "version": "0.3.4",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
// Локальный мок для разработки фронтенда: эмулирует awg-ui + awg-ctrl целиком в
// памяти, AWG не нужен. Логин admin / admin. Запуск: npm run dev:mock.
import express from "express";
import crypto from "crypto";
import { encodeSenLink } from "./sen";
const app = express();
app.use(express.json());
const now = () => Math.floor(Date.now() / 1000);
const rnd = (n: number) => Math.floor(Math.random() * n);
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
const GEN = "3.1";
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 }
const users: U[] = ["alice", "bob", "laptop-ivan"].map((name, i) => ({
name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"),
key_gen: GEN, online: i !== 1, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
}));
// ── серверы: core (id 0), нода в сети и нода, ещё не подключавшаяся ──
// Пользователи хранятся по серверам; X-Server-Id выбирает набор, как в настоящей панели.
const usersOf: Record<number, U[]> = {
0: users,
1: ["dave", "erin"].map((name, i) => ({
name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"),
key_gen: "2", online: i === 0, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
})),
};
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 }
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 }, // старый агент: версию не шлёт
];
let nodeId = 3;
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
// ── 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 }];
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[] }
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 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 } : {}) });
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (q, r) => {
const id = srv(q), n = mnodes.find(x => x.id === id);
r.json({ status: "ok", server: n?.name ?? "VPN (mock)", ip: n?.ip ?? "203.0.113.7", gen: n?.gen ?? GEN, awg: { status: "ok", peers: usersOf[id].length, module: "3.0.1", tools: "1.0.0" } });
});
// ── серверы ──
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,
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.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 });
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.post("/awg/restart", (_q, r) => r.json({ up: true }));
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) }));
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
app.post("/api/users", (q, r) => {
const users = U_(q);
const name = String(q.body.name ?? "");
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" });
if (users.some(u => u.name === name)) return r.status(409).json({ error: "Пользователь уже существует" });
const u: U = { name, ip: `10.9.0.${users.length + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), key_gen: GEN, online: false, lastHandshake: 0, rx: 0, tx: 0 };
users.push(u); r.status(201).json(u);
});
app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); });
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.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] };
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 });
});
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.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
});
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : r.status(404).json({ error: "Не найден" }); });
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
+383
View File
@@ -0,0 +1,383 @@
// 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.
//
// Несколько серверов под одной панелью: core (эта awg-ui) и nodes — VPN-серверы,
// которые сами открывают к core исходящее WSS-соединение (awg-agent). Реестр нод
// лежит в ui.db, RPC к ноде идёт по этому соединению, а нода исполняет запрос
// на своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
//
// server_id 0 — локальный awg-ctrl этой же машины (как было до нод); при
// LOCAL_NODE=off его нет, core работает как чистая панель. Протокол и формат
// join-строки — docs/node-protocol.md.
import crypto from "crypto";
import fs from "fs";
import https from "https";
import path from "path";
import { spawnSync } from "child_process";
import { Router, type Request, type Response } from "express";
import type Database from "better-sqlite3";
import { WebSocketServer, WebSocket } from "ws";
import { spkiPin, publicKeyFromRaw } from "./sen";
export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
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;
}
interface Health {
server: string; ip: string; gen: string; peers: number;
awg_up: boolean; module: string; tools: string;
}
interface Conn {
ws: WebSocket; seq: number; alive: boolean;
pending: Map<number, { resolve: (r: { status: number; data: any }) => void; timer: NodeJS.Timeout }>;
}
const JOIN_TTL = 3600; // секунд жизни одноразового секрета
const AUTH_TIMEOUT = 10_000; // на challenge–response после открытия сокета
const HEALTH_EVERY = 15_000;
const PING_EVERY = 20_000;
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 }) {
const { uidb, ctrlLocal } = deps;
const NODE_PORT = Number(process.env.NODE_PORT) || 0;
const LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() !== "off";
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
// ── TLS: самоподписанный сертификат, pin которого зашит в join-строки ────
// Живёт столько же, сколько core: пересоздать = переподключить все ноды.
let tls: { key: Buffer; cert: Buffer } | null = null;
let pin: Buffer | null = null;
if (NODE_PORT) {
const keyF = file(process.env.NODE_TLS_KEY_FILE, "node_tls.key");
const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt");
try {
if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) {
const r = spawnSync("openssl", [
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node",
], { stdio: "ignore" });
if (r.status !== 0) throw new Error("openssl");
fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600);
console.log("nodes: создан самоподписанный сертификат " + crtF);
}
tls = { key: fs.readFileSync(keyF), cert: fs.readFileSync(crtF) };
pin = spkiPin(tls.cert.toString());
} catch {
console.warn("nodes: сертификат хаба недоступен (" + crtF + ") — приём нод отключён");
tls = null;
}
}
// ── Схема ──────────────────────────────────────────────────────────────
uidb.exec(`
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
pub TEXT,
join_hash BLOB,
join_expires INTEGER,
created_at INTEGER NOT NULL,
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 = ?"),
insert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO nodes (name, join_hash, join_expires, created_at) VALUES (?, ?, ?, ?)"),
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 = ?"),
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
};
const now = () => Math.floor(Date.now() / 1000);
const sha = (b: Buffer) => crypto.createHash("sha256").update(b).digest();
// ── RPC к нодам ────────────────────────────────────────────────────────
const conns = new Map<number, Conn>();
const health = new Map<number, Health>();
const OFFLINE = { status: 502, data: { error: "Сервер недоступен" } };
function rpc(id: number, method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
const c = conns.get(id);
if (!c || c.ws.readyState !== WebSocket.OPEN) return Promise.resolve(OFFLINE);
return new Promise(resolve => {
const rid = ++c.seq;
const timer = setTimeout(() => {
c.pending.delete(rid);
resolve({ status: 504, data: { error: "Сервер не ответил" } });
}, urlPath.startsWith("/awg/") ? RPC_SLOW : RPC_TIMEOUT);
c.pending.set(rid, { resolve, timer });
c.ws.send(JSON.stringify({ t: "req", id: rid, method, path: urlPath, body }));
});
}
/** Контроллер нужного сервера. 0 — локальный awg-ctrl, остальное — нода по WSS. */
function ctrlFor(serverId: number): Ctrl {
if (serverId === 0) {
if (!LOCAL) return async () => ({ status: 404, data: { error: "Локальный сервер не настроен" } });
return ctrlLocal;
}
if (!q.byId.get(serverId)) return async () => ({ status: 404, data: { error: "Сервер не найден" } });
return (m, p, b) => rpc(serverId, m, p, b);
}
const exists = (id: number) => (id === 0 ? LOCAL : q.byId.get(id) !== undefined);
/** Есть ли сейчас связь с сервером (локальный считаем доступным всегда — до первого отказа awg-ctrl). */
const isOnline = (id: number) => (id === 0 ? LOCAL : conns.has(id));
/** Все серверы реестра: локальный (если есть) и ноды. */
const ids = (): number[] => [...(LOCAL ? [0] : []), ...(q.all.all() as NodeRow[]).map(n => n.id)];
// Подписчики на «сервер снова на связи» — подписка по этому сигналу догоняет пропущенное.
const onlineHandlers: ((id: number) => void)[] = [];
const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); };
// Подписчики на «сервер удаляют». beforeDelete — проверка: может запретить удаление и не
// меняет ui.db (запретить может и следующий подписчик). forcible — запрет снимается
// подтверждением оператора (force): сервер не всё успел подтвердить, а не используется.
// afterDelete — уборка своих ссылок на сервер, когда он уже удалён.
type DeleteVeto = { error: string; forcible: boolean } | null;
const deleteHandlers: ((id: number, force: boolean) => Promise<DeleteVeto>)[] = [];
const beforeDelete = (cb: (id: number, force: boolean) => Promise<DeleteVeto>) => { deleteHandlers.push(cb); };
const afterHandlers: ((id: number) => void)[] = [];
const afterDelete = (cb: (id: number) => void) => { afterHandlers.push(cb); };
/** Сервер по умолчанию: локальный, а без него — первая нода. */
function defaultId(): number {
if (LOCAL) return 0;
return (q.all.all() as NodeRow[])[0]?.id ?? 0;
}
async function refreshHealth(id: number) {
const r = await (id === 0 ? ctrlLocal("GET", "/health") : rpc(id, "GET", "/health")).catch(() => null);
const d = r?.data;
if (d && typeof d === "object" && typeof d.server === "string") {
health.set(id, {
server: d.server, ip: d.ip ?? "", gen: d.gen ?? "2", peers: d.awg?.peers ?? 0,
awg_up: d.awg?.status === "ok", module: d.awg?.module ?? "", tools: d.awg?.tools ?? "",
});
} else health.delete(id);
}
// ── Хаб: приём соединений от нод ───────────────────────────────────────
const failHits = new Map<string, { n: number; reset: number }>();
const failed = (ip: string) => {
const t = Date.now(), e = failHits.get(ip);
if (!e || e.reset < t) failHits.set(ip, { n: 1, reset: t + 15 * 60_000 });
else e.n++;
};
const blocked = (ip: string) => {
const e = failHits.get(ip);
return !!e && e.reset > Date.now() && e.n >= 20;
};
function dropConn(id: number, c: Conn) {
for (const p of c.pending.values()) { clearTimeout(p.timer); p.resolve(OFFLINE); }
c.pending.clear();
if (conns.get(id) === c) { conns.delete(id); health.delete(id); }
}
function onConnection(ws: WebSocket, req: { socket: { remoteAddress?: string } }) {
const ip = req.socket.remoteAddress ?? "unknown";
if (blocked(ip)) { ws.close(4029, "rate_limited"); return; }
const nonce = crypto.randomBytes(16).toString("base64url");
let conn: Conn | null = null;
let nodeId = 0;
const authTimer = setTimeout(() => ws.close(4001, "auth_timeout"), AUTH_TIMEOUT);
const deny = () => { failed(ip); ws.close(4003, "unauthorized"); };
function authenticate(m: any) {
if (m?.t !== "auth" || !Number.isInteger(m.node_id) || typeof m.sig !== "string") return deny();
const row = q.byId.get(m.node_id) as NodeRow | undefined;
if (!row) return deny();
let pubB64: string | null = row.pub;
const joining = pubB64 === null;
if (joining) {
const j = m.join;
if (!row.join_hash || !row.join_expires || row.join_expires < now()
|| typeof j?.secret !== "string" || typeof j?.pub !== "string") return deny();
const h = sha(Buffer.from(j.secret, "base64url"));
if (h.length !== row.join_hash.length || !crypto.timingSafeEqual(h, row.join_hash)) return deny();
pubB64 = j.pub;
}
try {
const key = publicKeyFromRaw(Buffer.from(pubB64 as string, "base64url"));
if (!crypto.verify(null, authMsg(nonce, row.id), key, Buffer.from(m.sig, "base64url"))) return deny();
} catch { return deny(); }
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);
const old = conns.get(row.id);
conn = { ws, seq: 0, alive: true, pending: new Map() };
conns.set(row.id, conn);
nodeId = row.id;
if (old) { dropConn(row.id, old); conns.set(row.id, conn); old.ws.close(4000, "replaced"); }
ws.send(JSON.stringify({ t: "ready" }));
console.log(`nodes: «${row.name}» (#${row.id}) подключена${joining ? " (первое подключение)" : ""}`);
void refreshHealth(row.id);
for (const cb of onlineHandlers) { try { cb(row.id); } catch { /* подписчик сам разберётся */ } }
}
ws.on("message", raw => {
let m: any;
try { m = JSON.parse(raw.toString()); } catch { ws.close(4002, "bad_frame"); return; }
if (!conn) { authenticate(m); return; }
if (m?.t === "res" && Number.isInteger(m.id)) {
const p = conn.pending.get(m.id);
if (!p) return;
conn.pending.delete(m.id);
clearTimeout(p.timer);
p.resolve({ status: Number(m.status) || 502, data: m.data });
}
});
ws.on("pong", () => { if (conn) conn.alive = true; });
ws.on("close", () => {
clearTimeout(authTimer);
if (conn) { dropConn(nodeId, conn); console.log(`nodes: #${nodeId} отключена`); }
});
ws.on("error", () => { /* close придёт следом */ });
ws.send(JSON.stringify({ t: "hello", nonce }));
}
function start() {
if (LOCAL) {
void refreshHealth(0);
for (const cb of onlineHandlers) { try { cb(0); } catch { /* подписчик сам разберётся */ } }
}
if (!NODE_PORT || !tls) return;
const server = https.createServer(tls);
const wss = new WebSocketServer({ server, path: "/node/v1", maxPayload: 4 * 1024 * 1024 });
wss.on("connection", onConnection);
server.listen(NODE_PORT, () => console.log(`nodes: hub on :${NODE_PORT} (wss)`));
setInterval(() => {
for (const [id, c] of conns) {
if (!c.alive) { c.ws.terminate(); dropConn(id, c); continue; }
c.alive = false;
try { c.ws.ping(); } catch { /* закроется само */ }
}
}, PING_EVERY).unref();
setInterval(() => { for (const id of conns.keys()) void refreshHealth(id); }, HEALTH_EVERY).unref();
}
// ── Роуты панели (монтируются в server.ts за JWT) ──────────────────────
const joinString = (host: string, id: number, secret: Buffer) => "awgjoin://" + Buffer.from(JSON.stringify({
v: 1, h: host, p: NODE_PORT, pin: (pin as Buffer).toString("base64url"), n: id, s: secret.toString("base64url"),
})).toString("base64url");
const hostOf = (req: Request): string => {
const given = (req.body as { host?: unknown } | undefined)?.host;
if (typeof given === "string" && HOST_RE.test(given)) return given;
return (req.headers.host ?? "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
};
const idOf = (req: Request) => Number(req.params.id);
const validId = (n: number) => Number.isInteger(n) && n >= 1;
const hubReady = () => NODE_PORT > 0 && tls !== null && pin !== null;
const router = Router();
router.get("/", async (_req: Request, res: Response) => {
if (LOCAL) await refreshHealth(0);
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 });
}
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,
});
}
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes });
});
router.post("/", (req: Request, res: Response) => {
if (!hubReady()) { res.status(503).json({ error: "Приём нод не настроен (NODE_PORT / сертификат)" }); return; }
const name = (req.body as { name?: unknown } | undefined)?.name;
if (typeof name !== "string" || !NAME_RE.test(name)) { res.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" }); return; }
const host = hostOf(req);
if (!HOST_RE.test(host)) { res.status(400).json({ error: "Не удалось определить адрес core" }); return; }
const secret = crypto.randomBytes(16);
const id = Number(q.insert.run(name, sha(secret), now() + JOIN_TTL, now()).lastInsertRowid);
res.status(201).json({ id, name, join: joinString(host, id, secret), expires_in: JOIN_TTL });
});
// Новая join-строка для ноды, которая ещё не подключалась (истёк срок / потеряли).
router.post("/:id/join", (req: Request, res: Response) => {
if (!hubReady()) { res.status(503).json({ error: "Приём нод не настроен (NODE_PORT / сертификат)" }); return; }
const id = idOf(req);
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
if (n.pub !== null) { res.status(409).json({ error: "Нода уже подключена" }); return; }
const host = hostOf(req);
if (!HOST_RE.test(host)) { res.status(400).json({ error: "Не удалось определить адрес core" }); return; }
const secret = crypto.randomBytes(16);
q.rejoin.run(sha(secret), now() + JOIN_TTL, id);
res.json({ id, name: n.name, join: joinString(host, id, secret), expires_in: JOIN_TTL });
});
router.patch("/: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; }
const name = (req.body as { name?: unknown } | undefined)?.name;
if (typeof name !== "string" || !NAME_RE.test(name)) { res.status(400).json({ error: "Неверное имя" }); return; }
q.rename.run(name, id);
res.json({ id, name });
});
router.delete("/:id", async (req: Request, res: Response) => {
const id = idOf(req);
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
// Ключи API и мастер-ключи проверяют их владельцы в своих обработчиках (beforeDelete).
const force = req.query.force === "1";
for (const cb of deleteHandlers) {
let veto: DeleteVeto;
try { veto = await cb(id, force); } catch { veto = { error: "Не удалось подготовить сервер к удалению", forcible: true }; }
if (veto) { res.status(409).json({ error: veto.error, can_force: veto.forcible && !force }); return; }
}
if (!q.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
const c = conns.get(id);
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
q.delete.run(id);
// Хвосты подписки для этого сервера больше некому доделать.
for (const t of ["peer_tombstones", "server_profiles"]) {
try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ }
}
for (const cb of afterHandlers) { try { cb(id); } catch (e) { console.warn(`nodes: уборка после удаления #${id}: ${(e as Error).message}`); } }
res.json({ success: true, id });
});
return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, afterDelete, defaultId, start };
}
+6 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.2.2", "version": "0.3.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -15,17 +15,19 @@
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
"qrcode": "^1.5.0", "qrcode": "^1.5.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0" "react-dom": "^18.3.0",
"ws": "^8.22.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/qrcode": "^1.5.0",
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/qrcode": "^1.5.0",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"concurrently": "^9.0.0",
"express": "^4.19.0", "express": "^4.19.0",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^5.4.0", "typescript": "^5.4.0",
+234
View File
@@ -0,0 +1,234 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1.
// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент
// реализуется по ней, поэтому любое изменение здесь — изменение протокола.
import crypto from "crypto";
export const SEN_PREFIX = "sen://";
export const SEN_VERSION = 1;
export const FLAG_TLS = 0x01;
const ADDR_V4 = 0x04;
const ADDR_V6 = 0x06;
const ADDR_DOMAIN = 0x44;
export class SenLinkError extends Error {}
export interface SenAddr { host: string; port: number }
export interface SenLink {
tls: boolean;
addrs: SenAddr[];
secret: Buffer; // 16 байт
signPub: Buffer; // 32 байта, Ed25519
tlsPin?: Buffer; // 32 байта, только при tls
name: string;
}
// ── CRC32 (IEEE) ───────────────────────────────────────────────────────────
// zlib.crc32 появился только в Node 20.15, а целимся в 20.x целиком.
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
export function crc32(buf: Uint8Array): number {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// ── Адреса ─────────────────────────────────────────────────────────────────
function ipv6ToBytes(host: string): Buffer | null {
const parts = host.split("::");
if (parts.length > 2) return null;
const head = parts[0] ? parts[0].split(":") : [];
const tail = parts.length === 2 && parts[1] ? parts[1].split(":") : [];
const missing = 8 - head.length - tail.length;
if (parts.length === 1 ? head.length !== 8 : missing < 1) return null;
const groups = [...head, ...Array(parts.length === 2 ? missing : 0).fill("0"), ...tail];
if (groups.length !== 8) return null;
const out = Buffer.alloc(16);
for (let i = 0; i < 8; i++) {
if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
out.writeUInt16BE(parseInt(groups[i], 16), i * 2);
}
return out;
}
function bytesToIpv6(b: Buffer): string {
const g: string[] = [];
for (let i = 0; i < 16; i += 2) g.push(b.readUInt16BE(i).toString(16));
// Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи.
let bestStart = -1, bestLen = 0;
for (let i = 0; i < 8;) {
if (g[i] !== "0") { i++; continue; }
let j = i;
while (j < 8 && g[j] === "0") j++;
if (j - i > bestLen) { bestStart = i; bestLen = j - i; }
i = j;
}
if (bestLen < 2) return g.join(":");
return g.slice(0, bestStart).join(":") + "::" + g.slice(bestStart + bestLen).join(":");
}
function encodeAddr({ host, port }: SenAddr): Buffer {
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new SenLinkError(`Неверный порт: ${port}`);
const p = Buffer.alloc(2);
p.writeUInt16BE(port);
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
const o = host.split(".").map(Number);
if (o.some(n => n > 255)) throw new SenLinkError(`Неверный IPv4: ${host}`);
return Buffer.concat([Buffer.from([ADDR_V4, ...o]), p]);
}
if (host.includes(":")) {
const b = ipv6ToBytes(host);
if (!b) throw new SenLinkError(`Неверный IPv6: ${host}`);
return Buffer.concat([Buffer.from([ADDR_V6]), b, p]);
}
const h = Buffer.from(host, "utf8");
if (h.length < 1 || h.length > 253 || !/^[A-Za-z0-9.-]+$/.test(host))
throw new SenLinkError(`Неверный домен: ${host}`);
return Buffer.concat([Buffer.from([ADDR_DOMAIN, h.length]), h, p]);
}
// ── Ссылка ─────────────────────────────────────────────────────────────────
export function encodeSenLink(l: SenLink): string {
if (l.addrs.length < 1 || l.addrs.length > 3) throw new SenLinkError("Адресов должно быть от 1 до 3");
if (l.secret.length !== 16) throw new SenLinkError("secret: 16 байт");
if (l.signPub.length !== 32) throw new SenLinkError("signPub: 32 байта");
if (l.tls && l.tlsPin?.length !== 32) throw new SenLinkError("tlsPin: 32 байта при TLS");
const name = Buffer.from(l.name, "utf8");
if (name.length > 64) throw new SenLinkError("Имя: до 64 байт");
const body = Buffer.concat([
Buffer.from([SEN_VERSION, l.tls ? FLAG_TLS : 0, l.addrs.length]),
...l.addrs.map(encodeAddr),
l.secret,
l.signPub,
...(l.tls ? [l.tlsPin as Buffer] : []),
Buffer.from([name.length]),
name,
]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return SEN_PREFIX + Buffer.concat([body, crc]).toString("base64url");
}
export function decodeSenLink(link: string): SenLink {
const t = link.trim();
if (!t.toLowerCase().startsWith(SEN_PREFIX)) throw new SenLinkError("Ссылка должна начинаться с sen://");
const payload = t.slice(SEN_PREFIX.length).replace(/\s+/g, "");
if (!/^[A-Za-z0-9_-]+$/.test(payload)) throw new SenLinkError("Ссылка повреждена: недопустимые символы");
const raw = Buffer.from(payload, "base64url");
if (raw.length < 3 + 4) throw new SenLinkError("Ссылка слишком короткая");
const body = raw.subarray(0, raw.length - 4);
if (raw.readUInt32BE(raw.length - 4) !== crc32(body)) throw new SenLinkError("Ссылка повреждена: не сошлась контрольная сумма");
let off = 0;
const need = (n: number) => { if (off + n > body.length) throw new SenLinkError("Ссылка обрезана"); };
const take = (n: number) => { need(n); const s = body.subarray(off, off + n); off += n; return s; };
const version = take(1)[0];
if (version !== SEN_VERSION) throw new SenLinkError(`Неизвестная версия ссылки: ${version}`);
const flags = take(1)[0];
if (flags & ~FLAG_TLS) throw new SenLinkError("Неизвестные флаги ссылки");
const n = take(1)[0];
if (n < 1 || n > 3) throw new SenLinkError("Неверное число адресов");
const addrs: SenAddr[] = [];
for (let i = 0; i < n; i++) {
const type = take(1)[0];
let host: string;
if (type === ADDR_V4) host = [...take(4)].join(".");
else if (type === ADDR_V6) host = bytesToIpv6(Buffer.from(take(16)));
else if (type === ADDR_DOMAIN) {
const len = take(1)[0];
if (len < 1) throw new SenLinkError("Пустой домен");
host = Buffer.from(take(len)).toString("utf8");
if (!/^[A-Za-z0-9.-]+$/.test(host)) throw new SenLinkError("Неверный домен");
} else throw new SenLinkError(`Неизвестный тип адреса: ${type}`);
const port = Buffer.from(take(2)).readUInt16BE(0);
if (port === 0) throw new SenLinkError("Порт 0");
addrs.push({ host, port });
}
const secret = Buffer.from(take(16));
const signPub = Buffer.from(take(32));
const tls = (flags & FLAG_TLS) !== 0;
const tlsPin = tls ? Buffer.from(take(32)) : undefined;
const nameLen = take(1)[0];
if (nameLen > 64) throw new SenLinkError("Имя длиннее 64 байт");
const name = Buffer.from(take(nameLen)).toString("utf8");
if (off !== body.length) throw new SenLinkError("Лишние данные в конце ссылки");
return { tls, addrs, secret, signPub, tlsPin, name };
}
// ── Ключи Ed25519 в «сыром» виде (32 байта) ────────────────────────────────
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
export function rawPublicKey(key: crypto.KeyObject): Buffer {
const der = key.export({ type: "spki", format: "der" }) as Buffer;
return der.subarray(der.length - 32);
}
export function publicKeyFromRaw(raw: Buffer): crypto.KeyObject {
if (raw.length !== 32) throw new SenLinkError("Публичный ключ Ed25519: 32 байта");
return crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" });
}
/** SHA-256 от SPKI DER сертификата — то, что кладётся в ссылку как pin. */
export function spkiPin(certPem: string): Buffer {
const spki = new crypto.X509Certificate(certPem).publicKey.export({ type: "spki", format: "der" }) as Buffer;
return crypto.createHash("sha256").update(spki).digest();
}
// ── Подписи протокола ──────────────────────────────────────────────────────
// Ответ: {"body": "<JSON-строка>", "sig": "<base64url>"}; sig — Ed25519 над
// UTF-8 байтами строки body. body — именно строка, а не вложенный объект,
// чтобы клиенту не нужна была каноническая сериализация JSON.
export function signResponse(payload: unknown, signKey: crypto.KeyObject): { body: string; sig: string } {
const body = JSON.stringify(payload);
return { body, sig: crypto.sign(null, Buffer.from(body, "utf8"), signKey).toString("base64url") };
}
export function verifyResponse(res: { body: string; sig: string }, signPub: Buffer): boolean {
try {
return crypto.verify(null, Buffer.from(res.body, "utf8"), publicKeyFromRaw(signPub), Buffer.from(res.sig, "base64url"));
} catch { return false; }
}
// Запрос: подпись ключом устройства (auth_pub) над строкой
// METHOD \n /path \n ts \n hex(sha256(тело запроса))
// Тело пустое → sha256 пустой строки. path — без query.
export function requestSigString(method: string, urlPath: string, ts: number, body: Buffer | string): string {
const h = crypto.createHash("sha256").update(body).digest("hex");
return `${method.toUpperCase()}\n${urlPath}\n${ts}\n${h}`;
}
export function signRequest(
method: string, urlPath: string, ts: number, body: Buffer | string, authKey: crypto.KeyObject,
): string {
return crypto.sign(null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"), authKey).toString("base64url");
}
export function verifyRequest(
method: string, urlPath: string, ts: number, body: Buffer | string, sig: string, authPub: Buffer,
): boolean {
try {
return crypto.verify(
null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"),
publicKeyFromRaw(authPub), Buffer.from(sig, "base64url"),
);
} catch { return false; }
}
+127 -25
View File
@@ -7,6 +7,8 @@ import fs from "fs";
import crypto from "crypto"; import crypto from "crypto";
import axios from "axios"; import axios from "axios";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { createSub } from "./sub";
import { createNodes } from "./nodes";
const app = express(); const app = express();
const PORT = Number(process.env.PORT) || 8080; const PORT = Number(process.env.PORT) || 8080;
@@ -71,17 +73,56 @@ uidb.exec(`
server_id INTEGER NOT NULL DEFAULT 0, server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
last_used INTEGER last_used INTEGER
) );
-- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах
-- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users,
-- всегда входит в набор.
CREATE TABLE IF NOT EXISTS api_key_servers (
key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
PRIMARY KEY (key_id, server_id)
);
-- Ключи со времён одного сервера: набор — их server_id.
INSERT OR IGNORE INTO api_key_servers (key_id, server_id)
SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers);
`); `);
const keyStmts = { const keyStmts = {
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"), list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"), insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"), delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
byId: uidb.prepare<[number]>("SELECT * FROM api_keys WHERE id = ?"),
setDefault: uidb.prepare<[number, number]>("UPDATE api_keys SET server_id = ? WHERE id = ?"),
serversOf: uidb.prepare<[number]>("SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id"),
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)"),
serversClear: uidb.prepare<[number]>("DELETE FROM api_key_servers WHERE key_id = ?"),
keysOnServer: uidb.prepare<[number]>(
"SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?"),
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"), byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"), touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
}; };
const keyServers = (id: number) => (keyStmts.serversOf.all(id) as { server_id: number }[]).map(r => r.server_id);
// Набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в наборе, иначе —
// наименьший из набора.
function setKeyServers(id: number, ids: number[]) {
const row = keyStmts.byId.get(id) as ApiKeyRow;
uidb.transaction(() => {
keyStmts.serversClear.run(id);
for (const sid of ids) keyStmts.serverAdd.run(id, sid);
if (!ids.includes(row.server_id)) keyStmts.setDefault.run(ids[0], id);
})();
}
// Набор серверов из тела запроса: непустой, без повторов, только существующие.
function parseKeyServers(v: unknown): number[] | string {
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
const ids = [...new Set(v)];
if (!ids.every(id => Number.isInteger(id) && nodes.exists(id as number))) return "Такого сервера нет";
return (ids as number[]).sort((a, b) => a - b);
}
function hashKey(key: string): string { function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex"); return crypto.createHash("sha256").update(key).digest("hex");
} }
@@ -171,49 +212,65 @@ app.get("/ui/brand", (_req: Request, res: Response) => {
// Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут. // Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут.
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => { app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
res.json({ keys: keyStmts.list.all() }); res.json({ keys: (keyStmts.list.all() as ApiKeyRow[]).map(k => ({ ...k, servers: keyServers(k.id) })) });
}); });
// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию.
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => { app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
const { label, server_id } = req.body as { label?: string; server_id?: number }; const { label, server_id, servers } = req.body as { label?: string; server_id?: number; servers?: unknown };
if (!label || !/^[\w \-]{1,40}$/.test(label)) { if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return; res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
} }
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится const ids = parseKeyServers(servers ?? [server_id === undefined ? nodes.defaultId() : Number(server_id)]);
// список серверов — валидировать его против реального реестра серверов. if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
const sid = server_id !== undefined && ids.includes(Number(server_id)) ? Number(server_id) : ids[0];
const { key, hash, prefix } = genApiKey(); const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000)); let id = 0;
uidb.transaction(() => {
id = Number(keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000)).lastInsertRowid);
for (const s of ids) keyStmts.serverAdd.run(id, s);
})();
// Открытое значение отдаётся один раз — дальше в БД только хэш. // Открытое значение отдаётся один раз — дальше в БД только хэш.
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key }); res.status(201).json({ id, label, prefix, key, server_id: sid, servers: ids });
});
// Сменить набор серверов, не перевыпуская ключ.
app.patch("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || !keyStmts.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
const ids = parseKeyServers((req.body as { servers?: unknown } | undefined)?.servers);
if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
setKeyServers(id, ids);
const row = keyStmts.byId.get(id) as ApiKeyRow;
res.json({ id, server_id: row.server_id, servers: ids });
}); });
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => { app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; } if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
keyStmts.delete.run(id); uidb.transaction(() => { keyStmts.serversClear.run(id); keyStmts.delete.run(id); })();
res.json({ success: true, id }); res.json({ success: true, id });
}); });
// Сервер, к которому относится запрос панели: заголовок X-Server-Id (0 — локальный
// awg-ctrl, остальное — нода). Без заголовка — сервер по умолчанию.
function serverIdOf(req: Request): number {
const raw = req.headers["x-server-id"];
if (raw === undefined) return nodes.defaultId();
const n = Number(raw);
return Number.isInteger(n) && n >= 0 ? n : nodes.defaultId();
}
async function proxy(req: Request, res: Response) { async function proxy(req: Request, res: Response) {
try { try {
const r = await axios({ const r = await nodes.ctrlFor(serverIdOf(req))(req.method, req.originalUrl, req.body);
method: req.method,
url: CTRL + req.originalUrl,
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
data: req.body,
validateStatus: () => true,
});
res.status(r.status).json(r.data); res.status(r.status).json(r.data);
} catch { } catch {
res.status(502).json({ error: "awg-ctrl недоступен" }); res.status(502).json({ error: "awg-ctrl недоступен" });
} }
} }
// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий interface ExtRequest extends Request { apiKey?: ApiKeyRow; servers?: number[]; serverId?: number; }
// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный
// awg-ctrl по (req as ExtRequest).apiKey.server_id.
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
function requireApiKey(req: Request, res: Response, next: NextFunction) { function requireApiKey(req: Request, res: Response, next: NextFunction) {
const key = (req.headers["x-api-key"] as string) ?? ""; const key = (req.headers["x-api-key"] as string) ?? "";
@@ -222,6 +279,18 @@ function requireApiKey(req: Request, res: Response, next: NextFunction) {
if (!row) { res.status(401).json({ error: "Invalid API key" }); return; } if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id); keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
(req as ExtRequest).apiKey = row; (req as ExtRequest).apiKey = row;
(req as ExtRequest).servers = keyServers(row.id);
next();
}
// Сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка — сервер ключа по
// умолчанию. Сервер вне набора — как несуществующий.
function pickKeyServer(req: Request, res: Response, next: NextFunction) {
const r = req as ExtRequest;
const raw = req.headers["x-server-id"];
const id = raw === undefined ? r.apiKey!.server_id : Number(raw);
if (!Number.isInteger(id) || !r.servers!.includes(id)) { res.status(404).json({ error: "Сервер не найден" }); return; }
r.serverId = id;
next(); next();
} }
@@ -242,6 +311,10 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
const ext = express.Router(); const ext = express.Router();
ext.use(requireApiKey); ext.use(requireApiKey);
// vpn://-пользователи живут на одном сервере — на выбранном pickKeyServer.
ext.use("/users", pickKeyServer);
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).serverId ?? 0);
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не // Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ // отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
// тот же, что у proxy() в такой ситуации. // тот же, что у proxy() в такой ситуации.
@@ -252,26 +325,26 @@ const wrap = (fn: Handler) => (req: Request, res: Response) => {
ext.post("/users", wrap(async (req, res) => { ext.post("/users", wrap(async (req, res) => {
const { name } = (req.body ?? {}) as { name?: string }; const { name } = (req.body ?? {}) as { name?: string };
const r = await ctrl("POST", "/api/users", { name }); const r = await ctrlOf(req)("POST", "/api/users", { name });
if (r.status >= 400) { res.status(r.status).json(r.data); return; } if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser; const u = r.data as ExtUser;
res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
})); }));
ext.get("/users", wrap(async (_req, res) => { ext.get("/users", wrap(async (req, res) => {
const r = await ctrl("GET", "/api/users/stats"); const r = await ctrlOf(req)("GET", "/api/users/stats");
res.status(r.status).json(r.data); res.status(r.status).json(r.data);
})); }));
ext.get("/users/:name", wrap(async (req, res) => { ext.get("/users/:name", wrap(async (req, res) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`); const r = await ctrlOf(req)("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; } if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser; const u = r.data as ExtUser;
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
})); }));
ext.delete("/users/:name", wrap(async (req, res) => { ext.delete("/users/:name", wrap(async (req, res) => {
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`); const r = await ctrlOf(req)("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data); res.status(r.status).json(r.data);
})); }));
@@ -279,6 +352,33 @@ ext.delete("/users/:name", wrap(async (req, res) => {
// его и потребует JWT вместо ключа. // его и потребует JWT вместо ключа.
app.use("/api/v1", ext); 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 });
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); app.use(["/api", "/health", "/awg"], requireAuth, proxy);
app.use(express.static(path.join(__dirname, "dist"))); app.use(express.static(path.join(__dirname, "dist")));
@@ -288,3 +388,5 @@ app.get("*", (_req: Request, res: Response) => {
}); });
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); }); app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
nodes.start();
sub.start();
+79
View File
@@ -24,6 +24,9 @@ html, body, #root { height: 100%; }
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922; --success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85; --neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a; --error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
--warn-dot: #e08a2c;
--tertiary-container: #ffdcad; /* design.md: tertiary-container */
--on-tertiary-container: #7a4a14;
--danger-border: #e6c4bc; --danger-border: #e6c4bc;
--danger-text: #b34a3a; --danger-text: #b34a3a;
@@ -65,6 +68,9 @@ html, body, #root { height: 100%; }
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f; --success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085; --neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449; --error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
--warn-dot: #ffb76b;
--tertiary-container: #4a3218; /* тёплый тёмный, как у статусов в тёмной теме */
--on-tertiary-container: #ffdcad;
--danger-border: #6e4540; --danger-border: #6e4540;
--danger-text: #ffb4ab; --danger-text: #ffb4ab;
@@ -373,6 +379,7 @@ body {
.server-bar { .server-bar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.server-card { .server-card {
@@ -387,6 +394,45 @@ body {
user-select: none; user-select: none;
} }
.server-card--active { border-color: var(--primary-container); } .server-card--active { border-color: var(--primary-container); }
.server-card--inactive { cursor: pointer; }
.server-card--inactive:hover { background: var(--state-primary); }
.server-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* «Добавить сервер»: призрачная кнопка с пунктиром (design.md, 4.2) */
.server-add {
background: transparent;
border: 1.5px dashed var(--outline);
color: var(--neutral-text);
}
.server-add:disabled { opacity: .6; cursor: not-allowed; }
/* Модалка добавления ноды */
.node-scrim {
position: fixed; inset: 0; z-index: 60;
background: var(--scrim);
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.node-modal {
width: 100%; max-width: 520px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 20px;
display: flex; flex-direction: column; gap: 12px;
}
.node-modal-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
.node-modal-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.node-modal-cmd {
display: block;
font-family: var(--mono); font-size: 12px;
background: var(--surface-container);
border-radius: var(--r-table);
padding: 12px;
word-break: break-all;
user-select: all;
}
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
.server-card-info { .server-card-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -407,6 +453,16 @@ body {
line-height: 1.2; line-height: 1.2;
} }
.server-gen-wrap { align-self: flex-start; margin-top: 2px; } .server-gen-wrap { align-self: flex-start; margin-top: 2px; }
.server-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.server-chips .server-gen-wrap, .server-chips .server-gen-chip { margin-top: 0; }
.server-ver-chip {
font-family: var(--mono);
font-size: 11px;
padding: 2px 8px;
background: var(--neutral-bg);
color: var(--neutral-text);
}
.server-ver-chip--warn { background: var(--tertiary-container); color: var(--on-tertiary-container); }
.server-gen-chip { .server-gen-chip {
align-self: flex-start; align-self: flex-start;
margin-top: 2px; margin-top: 2px;
@@ -654,6 +710,9 @@ tbody td:last-child { padding-right: 20px; }
border-color: var(--primary-container); border-color: var(--primary-container);
padding: 12px 14px; padding: 12px 14px;
} }
.server-card--inactive { border-color: var(--outline-variant); }
.server-add { width: 100%; justify-content: center; }
.node-modal-actions .btn { flex: 1; }
.server-toggle { display: flex; } .server-toggle { display: flex; }
.server-online-chip { display: none; } .server-online-chip { display: none; }
@@ -687,3 +746,23 @@ tbody td:last-child { padding-right: 20px; }
} }
.qr-img { width: 100%; height: auto; aspect-ratio: 1; } .qr-img { width: 100%; height: auto; aspect-ratio: 1; }
} }
/* Выбор набора серверов галочками (components/ServerPicker): серверы мастер-ключа,
видимость API-ключа */
.server-pick {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container);
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
color: var(--on-surface);
cursor: pointer;
}
.server-pick input { accent-color: var(--primary); margin: 0; }
.server-pick:hover { background: var(--state-primary); }
.server-pick--locked { cursor: default; }
.server-pick--locked:hover { background: var(--surface-container); }
.server-pick-dot { padding: 0; background: none; }
.server-pick-name { font-weight: 500; }
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
.server-pick-ip { font-family: var(--mono); margin-left: auto; }
+95 -19
View File
@@ -4,20 +4,28 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import './App.css'; import './App.css';
import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared'; import {
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId,
type NodeInfo, type Brand,
} from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons'; import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import ServerBar from './components/ServerBar'; import ServerBar from './components/ServerBar';
import AddServerModal from './components/AddServerModal';
import { TABS } from './tabs'; import { TABS } from './tabs';
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token, // Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит // список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
// из реестра TABS — App про конкретные вкладки ничего не знает. // из реестра TABS — App про конкретные вкладки ничего не знает.
export default function App() { export default function App() {
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? ''); const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
const [loginUser, setLoginUser] = useState(''); const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState(''); const [loginPass, setLoginPass] = useState('');
const [statusText, setStatusText] = useState(''); const [statusText, setStatusText] = useState('');
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null); 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(''); const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id); const [activeTab, setActiveTab] = useState(TABS[0].id);
@@ -33,17 +41,35 @@ export default function App() {
setTimeout(() => setMsg(''), 3000); setTimeout(() => setMsg(''), 3000);
}, []); }, []);
// Проверяет токен и подтягивает данные сервера (/health). Данные конкретных // Выбрать сервер: id уходит заголовком X-Server-Id во все запросы вкладок
// вкладок (юзеры, ключи) грузят сами вкладки. // (setServerId — до смены state, чтобы вкладка с новым key читала уже с ним).
const startSession = useCallback(async (tok: string) => { const pickServer = useCallback((id: number | null) => {
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } }); setServerId(id);
setServerInfo({ setServerIdState(id);
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2', try { if (id !== null) localStorage.setItem(SERVER_KEY, String(id)); } catch { /* не критично */ }
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
});
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
}, []); }, []);
// Список серверов (core + ноды) с их состоянием. Если выбранного больше нет —
// берём сохранённый, иначе первый.
const loadNodes = useCallback(async (tok: string) => {
const { data } = await axios.get('/ui/nodes', { headers: { Authorization: `Bearer ${tok}` } });
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;
try { const raw = localStorage.getItem(SERVER_KEY); saved = raw === null ? null : Number(raw); } catch { /* не критично */ }
pickServer(list.find(n => n.id === saved)?.id ?? list[0]?.id ?? null);
}, [pickServer]);
// Проверяет токен и подтягивает список серверов. Данные конкретных вкладок
// (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => {
await loadNodes(tok);
}, [loadNodes]);
const login = useCallback(async () => { const login = useCallback(async () => {
try { try {
const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass }); const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass });
@@ -69,10 +95,31 @@ export default function App() {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
setToken(''); setToken('');
setStatusText(''); setStatusText('');
setServerInfo(null); setNodes([]);
setServerId(null);
setServerIdState(null);
setDrawerOpen(false); setDrawerOpen(false);
}, [token]); }, [token]);
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);
}
showMsg('Сервер удалён');
await loadNodes(token);
} catch (e) {
showMsg(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось удалить' : 'Не удалось удалить');
}
}, [token, showMsg, loadNodes]);
// Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl). // Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl).
// Соединения клиентов кратковременно прерываются — поэтому подтверждение. // Соединения клиентов кратковременно прерываются — поэтому подтверждение.
const restartAwg = useCallback(async () => { const restartAwg = useCallback(async () => {
@@ -111,6 +158,13 @@ export default function App() {
} }
}, [startSession]); }, [startSession]);
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
useEffect(() => {
if (!token) return;
const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000);
return () => clearInterval(t);
}, [token, loadNodes]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации). // Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант. // Если не ответил, остаётся фолбэк из констант.
useEffect(() => { useEffect(() => {
@@ -143,8 +197,7 @@ export default function App() {
return () => document.removeEventListener('keydown', h); return () => document.removeEventListener('keydown', h);
}, [drawerOpen]); }, [drawerOpen]);
const isOnline = statusText.startsWith('online:'); const statusLabel = statusText.replace(/^(online|offline):/, '');
const statusLabel = statusText.replace(/^(online|offline):/, '');
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0]; const tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён. // Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
const chrome = tab.chrome ?? {}; const chrome = tab.chrome ?? {};
@@ -161,7 +214,7 @@ export default function App() {
{theme === 'dark' ? <IcoSun /> : <IcoMoon />} {theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button> </button>
{token && showRestart && ( {token && showRestart && serverId !== null && (
<button <button
className={`awg-restart${restarting ? ' spinning' : ''}`} className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG" aria-label="Перезапустить AWG"
@@ -257,19 +310,42 @@ export default function App() {
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */} {/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
{showServerBar && ( {showServerBar && (
<ServerBar <ServerBar
serverInfo={serverInfo} nodes={nodes}
serverOnline={isOnline} serverId={serverId}
hubEnabled={hubEnabled}
panelVersion={panelVersion}
onSelect={pickServer}
onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })}
onDelete={deleteNode}
onRestartAwg={restartAwg} onRestartAwg={restartAwg}
restarting={restarting} restarting={restarting}
showRestart={showRestart} showRestart={showRestart}
/> />
)} )}
<tab.Page token={token} showMsg={showMsg} /> {serverId === null && showServerBar ? (
<p className="login-sub">
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
</p>
) : (
// key: при смене сервера вкладка монтируется заново и читает данные уже с ним
<tab.Page key={`${tab.id}:${serverId}`} token={token} showMsg={showMsg} />
)}
</main> </main>
</div> </div>
</> </>
)} )}
{modal && (
<AddServerModal
token={token}
node={modal.node}
onClose={() => setModal(null)}
onChanged={() => { loadNodes(token).catch(() => { /* следующий тик */ }); }}
showMsg={showMsg}
/>
)}
{msg && <div className="snack">{msg}</div>} {msg && <div className="snack">{msg}</div>}
</div> </div>
); );
+102
View File
@@ -0,0 +1,102 @@
// 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 { useEffect, useState } from 'react';
import axios from 'axios';
import { apiFetch, copyText, type NodeInfo } from '../lib/shared';
import { IcoCopy } from './icons';
interface Joined { id: number; name: string; join: string; expires_in: number }
// Добавление ноды: имя → одноразовая join-строка, которую выполняют на самой ноде
// (`awg-ctrl join …`). С `node` открывается сразу за новой строкой для уже
// созданной, но ещё не подключавшейся ноды.
export default function AddServerModal({ token, node, onClose, onChanged, showMsg }: {
token: string;
node?: NodeInfo;
onClose: () => void;
onChanged: () => void;
showMsg: (text: string) => void;
}) {
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<Joined | null>(null);
const [error, setError] = useState('');
const call = async (fn: () => Promise<Joined>) => {
setBusy(true); setError('');
try { setResult(await fn()); onChanged(); }
catch (e) { setError(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось' : 'Не удалось'); }
finally { setBusy(false); }
};
const create = () => call(() =>
apiFetch('POST', '/ui/nodes', token, { name: name.trim(), host: window.location.hostname }));
useEffect(() => {
if (node) void call(() => apiFetch('POST', `/ui/nodes/${node.id}/join`, token, { host: window.location.hostname }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const command = result ? `awg-ctrl join '${result.join}'` : '';
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Добавить сервер" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">{node ? `Подключение: ${node.name}` : 'Добавить сервер'}</h3>
{!node && !result && (
<>
<p className="node-modal-hint">
Нода — обычный сервер с установленным Forgetting. Она сама подключится к этой панели,
открывать на ней порты не нужно.
</p>
<input
className="field"
placeholder="Название сервера"
value={name}
maxLength={40}
autoFocus
onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && name.trim() && !busy && create()}
/>
</>
)}
{result && (
<>
<p className="node-modal-hint">
Выполните на сервере «{result.name}» от root. Ссылка одноразовая и действует
{' '}{Math.round(result.expires_in / 60)} мин.
</p>
<code className="node-modal-cmd">{command}</code>
<p className="node-modal-hint">
Канал шифруется TLS с проверкой отпечатка сертификата этой панели: подменить ноду
или панель по дороге не выйдет.
</p>
</>
)}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
{result ? (
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
) : !node && (
<button className="btn btn--primary" onClick={create} disabled={busy || !name.trim()}>
Создать
</button>
)}
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
</div>
</div>
</div>
);
}
+109 -35
View File
@@ -1,56 +1,130 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { type ServerInfo, genLabel } from '../lib/shared'; import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
import { IcoRefresh } from './icons'; 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;
}
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
// вкладки (пиры / абоненты).
export default function ServerBar({ export default function ServerBar({
serverInfo, serverOnline, onRestartAwg, restarting, nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete,
showRestart = true, onRestartAwg, restarting, showRestart = true,
}: { }: {
serverInfo: ServerInfo | null; nodes: NodeInfo[];
serverOnline: boolean; panelVersion: string | null;
serverId: number | null;
hubEnabled: boolean;
onSelect: (id: number) => void;
onAdd: () => void;
onJoin: (n: NodeInfo) => void;
onDelete: (n: NodeInfo) => void;
onRestartAwg: () => void; onRestartAwg: () => void;
restarting: boolean; restarting: boolean;
showRestart?: boolean; showRestart?: boolean;
}) { }) {
return ( return (
<div className="server-bar"> <div className="server-bar">
{serverInfo && ( {nodes.map(n => {
<div className="server-card server-card--active"> const h = n.health;
{/* toggle — виден на мобильном вместо чипа */} const active = n.id === serverId;
return (
<div <div
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`} key={n.id}
aria-hidden="true" className={`server-card server-card--${active ? 'active' : 'inactive'}`}
role="button"
tabIndex={0}
aria-pressed={active}
onClick={() => onSelect(n.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
> >
<div className="server-toggle-thumb" /> {/* toggle — виден на мобильном вместо чипа */}
</div> <div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
{/* чип онлайн/офлайн — виден на десктопе */} <div className="server-toggle-thumb" />
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}> </div>
<span className="chip-dot" /> {/* чип онлайн/офлайн — виден на десктопе */}
</span> <span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
<div className="server-card-info"> <span className="chip-dot" />
<span className="server-card-name">{serverInfo.name}</span> </span>
<span className="server-card-ip">{serverInfo.ip}</span> <div className="server-card-info">
{(serverInfo.mod || serverInfo.tools) ? ( <span className="server-card-name">{h?.server || n.name}</span>
<span {n.pending ? (
className="tip-wrap server-gen-wrap" <span className="server-card-ip">ждёт подключения</span>
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`} ) : !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="tip-wrap server-gen-wrap"
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
>
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
</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>}
{n.pending && (
<button
className="btn-icon btn-icon--neutral"
aria-label="Новая ссылка подключения"
onClick={e => { e.stopPropagation(); onJoin(n); }}
> >
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span> <IcoCopy />
</span> </button>
) : ( )}
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span> {!n.local && (
<button
className="btn-icon btn-icon--danger"
aria-label={`Удалить сервер ${n.name}`}
onClick={e => { e.stopPropagation(); onDelete(n); }}
>
<IcoTrash />
</button>
)} )}
</div> </div>
<span className="server-card-peers">{serverInfo.peers} peers</span> );
</div> })}
)} <span
className="tip-wrap"
data-tip={hubEnabled ? '' : 'Приём нод выключен: задайте NODE_PORT'}
>
<button className="btn server-add" onClick={onAdd} disabled={!hubEnabled}>
<IcoPlus /> Добавить сервер
</button>
</span>
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */} {/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && ( {showRestart && serverId !== null && (
<button <button
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`} className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
onClick={onRestartAwg} onClick={onRestartAwg}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { type NodeInfo, genLabel } from '../lib/shared';
// Выбор набора серверов галочками: серверы мастер-ключа, видимость API-ключа. Набор не
// бывает пустым — последний отмеченный сервер снять нельзя. onChange получает новый набор
// и сервер, который переключили.
export default function ServerPicker({ nodes, selected, onChange }: {
nodes: NodeInfo[];
selected: number[];
onChange: (next: number[], node: NodeInfo, added: boolean) => void;
}) {
return (
<>
{nodes.map(n => {
const on = selected.includes(n.id);
const last = on && selected.length === 1;
const h = n.health;
return (
<label className={`server-pick${last ? ' server-pick--locked' : ''}`} key={n.id}>
<input type="checkbox" checked={on} disabled={last}
onChange={() => onChange(on ? selected.filter(id => id !== n.id) : [...selected, n.id], n, !on)} />
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
<span className="chip-dot" />
</span>
<span className="server-pick-name">{h?.server || n.name}</span>
{h?.ip && <code className="server-pick-ip">{h.ip}</code>}
{h?.gen && <span className="server-pick-gen">{genLabel(h.gen)}</span>}
</label>
);
})}
</>
);
}
// Имя сервера для подписей: как в сервер-баре, иначе «#id».
export const serverName = (nodes: NodeInfo[], id: number): string => {
const n = nodes.find(x => x.id === id);
return n ? n.health?.server || n.name : `#${id}`;
};
+7
View File
@@ -35,6 +35,13 @@ export const IcoRefresh = () => (
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/> <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg> </svg>
); );
export const IcoSettings = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>
<circle cx="9" cy="7" r="2.5" fill="var(--surface-bright, transparent)"/>
<circle cx="15" cy="17" r="2.5" fill="var(--surface-bright, transparent)"/>
</svg>
);
export const IcoQR = () => ( export const IcoQR = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/> <rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
+38 -4
View File
@@ -32,7 +32,8 @@ export interface ApiKey {
id: number; id: number;
label: string; label: string;
prefix: string; prefix: string;
server_id: number; server_id: number; // сервер по умолчанию для /api/v1/users
servers: number[]; // какие серверы видит ключ
created_at: number; created_at: number;
last_used: number | null; last_used: number | null;
} }
@@ -47,10 +48,43 @@ export interface PageProps {
export const BRAND_FALLBACK = 'Forgetting'; export const BRAND_FALLBACK = 'Forgetting';
export interface Brand { brand: string; channel: string; version: string } export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token'; export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme'; export const THEME_KEY = 'awg_theme';
export const SERVER_KEY = 'awg_server';
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
export interface NodeInfo {
id: number;
name: string;
local: boolean; // id 0 — локальный awg-ctrl этой же машины
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 у вкладки, поэтому та
// перечитывает данные уже с новым сервером.
let currentServerId: number | null = null;
export const setServerId = (id: number | null) => { currentServerId = id; };
export const getServerId = () => currentServerId;
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> { export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body }); const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (currentServerId !== null) headers['X-Server-Id'] = String(currentServerId);
const r = await axios({ method, url: path, headers, data: body });
return r.data; return r.data;
} }
+15
View File
@@ -75,3 +75,18 @@ LICENSE file in the root directory of this source tree.
@media (max-width: 640px) { @media (max-width: 640px) {
.apikey-cards-list { display: flex; } .apikey-cards-list { display: flex; }
} }
/* Настройки ключа: какие серверы он видит */
.apikey-tr-tools td { padding-top: 0; }
.apikey-tools {
display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: 16px;
padding: 12px 16px 14px;
margin: 6px 0 8px;
}
.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
+80 -30
View File
@@ -1,32 +1,60 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react'; import { Fragment, useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared'; import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons'; import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import './apikeys.css'; import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается // Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App. // один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список // Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
// серверов — ключ будет привязываться к выбранному в сервер-баре. // управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор
// правится в настройках ключа.
export default function ApiKeysPage({ token, showMsg }: PageProps) { export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]); const [keys, setKeys] = useState<ApiKey[]>([]);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null); const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const data = await apiFetch('GET', '/ui/apikeys', token); const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []); setKeys(data.keys ?? []);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch { } catch {
showMsg('Ошибка загрузки ключей'); showMsg('Ошибка загрузки ключей');
} }
}, [token, showMsg]); }, [token, showMsg]);
const setServers = useCallback(async (k: ApiKey, next: number[], n: NodeInfo, added: boolean) => {
try {
await apiFetch('PATCH', `/ui/apikeys/${k.id}`, token, { servers: next });
showMsg(added ? `Ключ видит «${serverName(nodes, n.id)}»` : `«${serverName(nodes, n.id)}» убран из ключа`);
await load();
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
}
}, [token, nodes, load, showMsg]);
const serversText = (k: ApiKey) => (k.servers ?? [k.server_id]).map(id => serverName(nodes, id)).join(', ');
const toolbox = (k: ApiKey) => (
<section className="apikey-tools">
<h4 className="apikey-tools-title">Серверы ключа</h4>
<p className="apikey-tools-hint">
На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой
выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.
</p>
<ServerPicker nodes={nodes} selected={k.servers ?? [k.server_id]} onChange={(next, n, added) => setServers(k, next, n, added)} />
</section>
);
const createKey = useCallback(async () => { const createKey = useCallback(async () => {
if (!label.trim()) return; if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 }); const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
if (r.error) { showMsg(r.error); return; } if (r.error) { showMsg(r.error); return; }
setLabel(''); setLabel('');
setCreated({ label: r.label, key: r.key }); setCreated({ label: r.label, key: r.key });
@@ -44,8 +72,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
return ( return (
<> <>
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ. {/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
<div className="toolbar"> <div className="toolbar">
<input <input
className="field" className="field"
@@ -68,6 +95,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
<th>#</th> <th>#</th>
<th>Метка</th> <th>Метка</th>
<th>Ключ</th> <th>Ключ</th>
<th>Серверы</th>
<th>Создан</th> <th>Создан</th>
<th>Последний раз</th> <th>Последний раз</th>
<th></th> <th></th>
@@ -75,24 +103,36 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{keys.length === 0 ? ( {keys.length === 0 ? (
<tr><td colSpan={6} className="empty">Нет ключей</td></tr> <tr><td colSpan={7} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => ( ) : keys.map((k, i) => (
<tr key={k.id}> <Fragment key={k.id}>
<td className="td-num">{i + 1}</td> <tr>
<td>{k.label}</td> <td className="td-num">{i + 1}</td>
<td className="td-mono">{k.prefix}</td> <td>{k.label}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td> <td className="td-mono">{k.prefix}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td> <td>{serversText(k)}</td>
<td className="td-actions"> <td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<div className="actions"> <td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<button <td className="td-actions">
className="btn-icon btn-icon--danger" <div className="actions">
title="Удалить ключ" <button
onClick={() => deleteKey(k.id, k.label)} className="btn-icon"
><IcoTrash /></button> title="Серверы ключа"
</div> aria-label="Серверы ключа"
</td> aria-expanded={tools === k.id}
</tr> onClick={() => setTools(tools === k.id ? null : k.id)}
><IcoSettings /></button>
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
</Fragment>
))} ))}
</tbody> </tbody>
</table> </table>
@@ -110,13 +150,23 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
<span className="apikey-card-num">#{i + 1}</span> <span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span> <span className="apikey-card-label">{k.label}</span>
</div> </div>
<button <div className="apikey-card-btns">
className="btn btn--danger btn--sq" <button
aria-label="Удалить ключ" className="btn btn--tonal btn--sq"
onClick={() => deleteKey(k.id, k.label)} aria-label="Серверы ключа"
><IcoTrash /></button> aria-expanded={tools === k.id}
onClick={() => setTools(tools === k.id ? null : k.id)}
><IcoSettings /></button>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</div> </div>
<code className="apikey-card-prefix">{k.prefix}</code> <code className="apikey-card-prefix">{k.prefix}</code>
<span className="apikey-card-servers">Серверы: {serversText(k)}</span>
{tools === k.id && toolbox(k)}
<div className="apikey-card-meta"> <div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span> <span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span> <span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
+434
View File
@@ -0,0 +1,434 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, 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 Device {
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
}
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
// панель догонит, когда нода появится.
const syncLabel = (d: Device): string =>
d.servers_ok < d.servers_total ? ` · на ${d.servers_ok} из ${d.servers_total} серверов` : '';
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
const platformLabel = (d: Device): string =>
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<MasterKey[]>([]);
const [enabled, setEnabled] = useState(true);
const [tls, setTls] = useState(false);
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
const [label, setLabel] = useState('');
const [limit, setLimit] = useState('3');
const [devs, setDevs] = useState<Record<number, Device[]>>({});
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
const [limitEdit, setLimitEdit] = useState('');
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
const load = useCallback(async () => {
try {
const d = await apiFetch('GET', '/ui/masterkeys', token);
const list: MasterKey[] = d.keys ?? [];
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
const all = await Promise.all(list.filter(k => !k.deleting).map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
}, [token, showMsg]);
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
const act = useCallback(async (fn: () => Promise<unknown>, ok: string) => {
try { await fn(); showMsg(ok); await load(); }
catch (e) { showMsg(errText(e)); }
}, [load, showMsg]);
const create = useCallback(async () => {
if (!label.trim()) return;
await act(async () => {
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
const ready = nodes.filter(n => !n.pending).map(n => n.id);
await apiFetch('POST', '/ui/masterkeys', token, {
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
});
setLabel('');
}, 'Мастер-ключ создан');
}, [label, limit, 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]);
const fetchLink = useCallback(async (k: MasterKey) => {
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
return r as { link: string; tls: boolean };
}, [token]);
const showQR = useCallback(async (k: MasterKey) => {
try {
const { link, tls: t } = await fetchLink(k);
const dataUrl = await QRCode.toDataURL(link, { width: 560, margin: 2, errorCorrectionLevel: 'H' });
setModal({ label: k.label, link, tls: t, dataUrl });
} catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]);
const copyLink = useCallback(async (k: MasterKey) => {
try { await copyText((await fetchLink(k)).link); showMsg('Ссылка скопирована'); }
catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]);
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
// поэтому сперва спросить и назвать, кого именно.
const 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;
});
const openTools = (k: MasterKey) => { setLimitEdit(String(k.device_limit)); setTools(tools === k.id ? null : k.id); };
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>
<button className="btn-icon" title="Скопировать ссылку" aria-label="Скопировать ссылку" onClick={() => copyLink(k)}><IcoCopy /></button>
<button className="btn-icon" title="Настройки мастер-ключа" aria-label="Настройки мастер-ключа" aria-expanded={tools === k.id} onClick={() => openTools(k)}><IcoSettings /></button>
<button className="btn-icon btn-icon--danger" title="Удалить мастер-ключ" aria-label="Удалить мастер-ключ" onClick={() => remove(k)}><IcoTrash /></button>
</div>
);
const toolbox = (k: MasterKey) => (
<div className="mk-tools">
{/* Слева — настройки самого ключа */}
<section className="mk-box">
<h4 className="mk-box-title">Настройки ключа</h4>
<div className="mk-setting">
<div className="mk-setting-text">
<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>
<span className="mk-setting-hint">Сколько устройств может подключить ключ</span>
</div>
<div className="mk-setting-ctl">
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
<button className="btn btn--tonal" onClick={() => saveLimit(k)}>
Сохранить
</button>
</div>
</div>
<div className="mk-setting">
<div className="mk-setting-text">
<span className="mk-setting-name">Ссылка</span>
<span className="mk-setting-hint">Старая перестанет принимать новые устройства</span>
</div>
<div className="mk-setting-ctl">
<button className="btn btn--tonal" onClick={() => {
if (confirm('Выпустить новую ссылку? Старая перестанет принимать новые устройства, уже добавленные продолжат работать.'))
act(() => apiFetch('POST', `/ui/masterkeys/${k.id}/rotate`, token), 'Ссылка перевыпущена');
}}>Перевыпустить</button>
</div>
</div>
<div className="mk-setting">
<div className="mk-setting-text">
<span className="mk-setting-name">Ключи устройств</span>
<span className="mk-setting-hint">Клиенты сменят их при следующем опросе</span>
</div>
<div className="mk-setting-ctl">
<button className="btn btn--tonal" onClick={() =>
act(() => apiFetch('POST', `/ui/masterkeys/${k.id}/rekey`, token), 'Смена ключей запрошена')}>
Запросить смену
</button>
</div>
</div>
</section>
{/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку —
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
<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)}» убран из ключа`)} />
<p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p>
</section>
</div>
);
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
const meter = (k: MasterKey, n: number) => (
<div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : n * 3 >= k.device_limit * 2 ? ' mk-meter--warn' : ''}`}
role="progressbar" aria-label="Устройства" aria-valuemin={0}
aria-valuemax={k.device_limit} aria-valuenow={n}
title={`Устройств: ${n} из ${k.device_limit}`}>
<span className="mk-meter-text"><span>{n}</span><span>{k.device_limit}</span></span>
<div className="mk-meter-fill"
style={{ width: `${Math.min(100, (n / Math.max(1, k.device_limit)) * 100)}%` }}>
{/* тот же текст поверх заливки — светлый, чтобы читался на ней */}
<span className="mk-meter-text mk-meter-text--on" aria-hidden="true"><span>{n}</span><span>{k.device_limit}</span></span>
</div>
</div>
);
const deviceActions = (d: Device) => (
<div className="actions">
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={async () => {
try {
const r = await apiFetch('POST', `/ui/devices/${d.id}/psk`, token);
showMsg(r.pending ? `PSK обновлён; ещё на ${r.pending} — когда сервер будет на связи` : 'PSK обновлён');
await load();
} catch (e) { showMsg(errText(e)); }
}}><IcoRefresh /></button>
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
}}><IcoTrash /></button>
</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)}
onClick={() => fold(k.id)}>▾</button>
);
const deviceNode = (k: MasterKey, d: Device) => (
<li className="mk-node mk-node--device" key={d.id}>
<div className="mk-device">
<div className="mk-device-main">
<span className="mk-device-title">
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
</span>
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
</div>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
<div className="mk-device-traffic">
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
</div>
{deviceActions(d)}
</div>
</li>
);
return (
<>
{!enabled && (
<p className="mk-notice">Подписка не настроена на сервере: нет ключа подписи или SUB_PORT. Ссылки выдавать нельзя.</p>
)}
{enabled && !tls && (
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
)}
<div className="toolbar">
<input className="field" placeholder="Метка (напр. Семья)" value={label}
onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} />
<input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств"
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={create}><IcoPlus /> Создать ключ</button>
</div>
</div>
{/* Десктоп: таблица, иерархия — строка мастер-ключа и устройства под ней */}
<div className="table-card mk-table-card">
<table className="mk-table">
<thead>
<tr>
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th>
<th>↓ rx</th><th>↑ tx</th><th>Был</th><th>Устройства</th><th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
) : keys.map(k => {
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);
return (
<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>{meter(k, list.length)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td>
</tr>
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
{open && list.map((d, i) => (
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
<td><span className="mk-indent">{d.device_name || 'Устройство'}</span></td>
<td className="td-mono"><code className="mk-device-id" title={d.device_id}>{d.device_id}</code></td>
<td>{platformLabel(d)}</td>
<td>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
</td>
<td className="td-mono">{bytes(d.rx)}</td>
<td className="td-mono">{bytes(d.tx)}</td>
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td>
<td></td>
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr>
))}
{open && free > 0 && (
<tr className="mk-tr-free mk-last">
<td colSpan={9}><span className="mk-indent">Свободно: {free}</span></td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
{/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */}
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
<ul className="mk-tree">
{keys.map(k => {
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);
return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
{caret(k)}
<IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, list.length)}
{actions(k)}
</div>
{tools === k.id && toolbox(k)}
{!isFolded && (
<ul className="mk-children">
{list.map(d => deviceNode(k, d))}
{free > 0 && (
<li className="mk-node mk-node--free"><div className="mk-free">Свободно: {free}</div></li>
)}
</ul>
)}
</li>
);
})}
</ul>
)}
{modal && (
<div className="qr-backdrop" onClick={() => setModal(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">{modal.label}</p>
<img className="qr-img" src={modal.dataUrl} alt="QR" title="Нажми чтобы скопировать sen:// ссылку"
onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />
<p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p>
<div className="qr-actions">
<button className="btn btn--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setModal(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
+278
View File
@@ -0,0 +1,278 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Мастер-ключи»: дерево «мастер-ключ → устройства».
Общая дизайн-система — в App.css. */
.mk-notice {
font-size: 13px;
color: var(--error-text);
background: var(--error-bg);
border-radius: 16px;
padding: 10px 16px;
}
.mk-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
.mk-limit-input { width: 88px !important; }
/* ── таблица (десктоп) ── */
.mk-table td, .mk-table th { white-space: nowrap; }
.mk-table .td-actions { width: 150px; }
.mk-table .mk-device-id { display: inline-block; max-width: 170px; vertical-align: middle; }
.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; }
.mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; }
.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; }
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
.mk-tr-master:hover td { background: var(--surface-container); }
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
/* 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); }
.mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */
.mk-tr-tools:hover { background: transparent; }
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: 12px; }
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
}
.mk-tr-device.mk-last td:first-child, .mk-tr-free.mk-last td:first-child { background-size: 2px 50%; }
.mk-indent { position: relative; display: inline-block; padding-left: 36px; }
.mk-indent::before {
content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
border-top: 2px solid var(--outline);
}
.mk-tr-free td { color: var(--neutral-dot); font-size: 12px; }
.mk-tr-free:hover { background: transparent; }
/* ── дерево (мобильный) ── */
.mk-tree, .mk-children { list-style: none; margin: 0; padding: 0; }
.mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */
.mk-root {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 12px 18px 14px;
}
/* родитель */
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
.mk-master-label {
font-size: 16px;
font-weight: 500;
color: var(--on-surface);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mk-caret {
width: 28px; height: 28px;
border: 0; border-radius: 50%;
background: transparent;
color: var(--neutral-text);
font-size: 16px;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s;
}
.mk-caret:hover { background: var(--state-neutral); }
.mk-caret--folded { transform: rotate(-90deg); }
.mk-tools {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: stretch;
margin: 6px 0 8px;
}
/* два одинаковых блока: слева настройки ключа, справа серверы */
.mk-box {
display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: 16px;
padding: 12px 16px 14px;
}
.mk-box-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
.mk-box .mk-box-title { margin-bottom: 2px; }
/* строка настройки: слева название и пояснение, справа контрол */
.mk-setting {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 10px 0;
border-top: 1px solid var(--outline-variant);
}
.mk-box-title + .mk-setting { border-top: 0; padding-top: 4px; }
.mk-setting:last-child { padding-bottom: 0; }
.mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mk-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; }
/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка».
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
.mk-meter {
--mk-w: 136px;
--meter-fill: var(--success-dot); /* < 2/3 — зелёный */
--meter-on: #2a2118; /* цвет числа поверх заливки */
position: relative;
width: var(--mk-w); height: 20px;
border-radius: var(--r-pill);
background: var(--outline-variant);
overflow: hidden;
flex-shrink: 0;
}
.mk-meter-fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--meter-fill);
overflow: hidden;
transition: width 0.25s, background 0.25s;
}
/* числа по краям внутри полоски: текущее слева, лимит справа */
.mk-meter-text {
position: absolute; left: 0; top: 0;
width: var(--mk-w); height: 100%;
box-sizing: border-box;
padding: 0 11px;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--on-surface);
}
.mk-meter-text--on { color: var(--meter-on); }
.mk-meter--warn { --meter-fill: var(--warn-dot); } /* от 2/3 — оранжевый */
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
.mk-box .tip-wrap { width: 100%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
.mk-children { margin: 8px 0 0 18px; padding-left: 28px; border-left: 2px solid var(--outline); }
.mk-node { position: relative; padding: 4px 0; }
.mk-node::before {
content: '';
position: absolute;
left: -28px; top: 50%;
width: 28px;
border-top: 2px solid var(--outline);
}
/* последний узел: линия родителя обрывается на его «ножке» */
.mk-node:last-child::after {
content: '';
position: absolute;
left: -30px; top: calc(50% + 1px); bottom: 0;
width: 2px;
background: var(--surface-bright);
}
.mk-device {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--surface-container);
border-radius: 14px;
padding: 8px 14px;
}
.mk-device-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 160px; }
.mk-device-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.mk-device-id {
font-family: var(--mono);
font-size: 11px;
color: var(--neutral-text);
background: var(--surface-bright);
border-radius: var(--r-pill);
padding: 1px 8px;
min-width: 0;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
.mk-metric { display: inline-flex; gap: 4px; }
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
.mk-free {
border: 1px dashed var(--outline);
border-radius: 14px;
padding: 10px 14px;
font-size: 13px;
color: var(--neutral-dot);
}
/* ── мобильный (design.md, часть IV): одна колонка, тач-цели ≥ 44px, rx/tx блоками
50/50, действия рядом с большим пальцем ── */
@media (max-width: 640px) {
.mk-tree { display: flex; }
.mk-limit-input { width: 100% !important; }
.mk-root { padding: 12px; }
/* родитель: шапка, затем ряд действий на всю ширину */
.mk-master { flex-wrap: wrap; row-gap: 10px; }
.mk-master .actions { width: 100%; gap: 8px; }
.mk-caret { width: 44px; height: 44px; margin: -6px 0 -6px -8px; }
.mk-meter { --mk-w: 112px; height: 24px; }
/* настройки узла: поля и кнопки на всю ширину */
.mk-tools { grid-template-columns: minmax(0, 1fr); margin: 8px 0 4px; }
.mk-setting { flex-direction: column; align-items: stretch; gap: 8px; }
.mk-setting-ctl { width: 100%; }
.mk-setting-ctl .btn { flex: 1; height: 44px; }
.mk-setting-ctl .mk-limit-input { width: 100% !important; flex: 1; height: 44px; }
.mk-tools .btn { width: 100%; height: 44px; justify-content: center; }
/* дерево уже: отступы поменьше, «ножки» короче */
.mk-children { margin-left: 8px; padding-left: 14px; }
.mk-node { padding: 4px 0; }
.mk-node::before { left: -14px; width: 14px; }
.mk-node:last-child::after { left: -16px; }
/* устройство — карточка: заголовок + чип, метрики 50/50, ряд действий */
.mk-device { border-radius: 16px; padding: 12px; gap: 10px; }
.mk-device-main { flex: 1 1 0; }
.mk-device-title { flex-wrap: wrap; row-gap: 2px; }
.mk-device-id { max-width: 100%; }
.mk-device-traffic { width: 100%; gap: 8px; }
.mk-metric {
flex: 1; flex-direction: column; gap: 2px;
background: var(--surface-bright);
border-radius: 16px; padding: 8px 12px;
}
.mk-metric i { font-family: var(--font, inherit); font-size: 12px; color: var(--neutral-text); }
.mk-metric-name::after { content: attr(data-sfx); }
.mk-metric b { font-size: 16px; font-weight: 500; color: var(--on-surface); }
/* кнопки действий: контурные, на всю ширину ряда, 44px */
.mk-device .actions { width: 100%; gap: 8px; }
.mk-master .btn-icon, .mk-device .btn-icon {
flex: 1; width: auto; height: 44px;
border: 1px solid var(--outline);
border-radius: var(--r-pill);
}
.mk-master .btn-icon--danger, .mk-device .btn-icon--danger { border-color: var(--error-dot); }
.mk-free { padding: 12px; font-size: 13px; }
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "masterkeys",
"label": "Мастер-ключи",
"icon": "key",
"order": 1.5
}
+967
View File
@@ -0,0 +1,967 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
// sen://-подписка: реестр мастер-ключей/устройств (ui.db), админ-роуты панели
// (/ui/masterkeys, /ui/devices, за JWT) и публичный листенер /sub/v1/* на
// отдельном порту SUB_PORT. Протокол и формат ссылки — docs/sen-link.md.
//
// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по
// публичному ключу (/api/peers*) и общий профиль сервера (/api/profile).
//
// Несколько серверов. Мастер-ключ отдаёт набор серверов (master_servers): локальный
// awg-ctrl (id 0) и/или ноды. Устройство регистрирует один ключ WireGuard, и он
// становится пиром на каждом сервере набора — IP и PSK на каждом свои (device_peers).
// Нода может быть не в сети, поэтому изменения не «толкаются», а сводятся: device_peers
// хранит, какой ключ сейчас стоит на сервере, 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";
import https from "https";
import path from "path";
import express, { Request, Response, NextFunction, Router } from "express";
import type Database from "better-sqlite3";
import {
encodeSenLink, rawPublicKey, publicKeyFromRaw, spkiPin,
signResponse, verifyRequest,
} from "./sen";
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
/** Что подписке нужно от реестра серверов (awg-ui/nodes.ts). */
export interface Servers {
ctrlFor(id: number): Ctrl;
exists(id: number): boolean;
isOnline(id: number): boolean;
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;
server_id: number; created_at: number; revoked_at: number | null;
}
interface DeviceRow {
id: number; master_id: number; device_id: string; device_name: string; platform: string;
pub_key: string; auth_pub: string; created_at: number; last_seen: number | null;
rekey_requested: number; version: string;
}
interface LinkRow {
device_id: number; server_id: number; peer_pub: string | null;
ip: string | null; psk: string | null; psk_pending: number;
}
interface Profile {
name: string; endpoint: string; server_pub: string; gen: string;
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
}
const TS_WINDOW = 300; // секунд, ±
const DEFAULT_LIMIT = 3;
const MAX_SERVERS = 32; // серверов в одном мастер-ключе
const SYNC_EVERY = 60_000; // фоновое сведение серверов
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
// pub_key WireGuard в пути к awg-ctrl — base64url (в обычном base64 есть «/»).
const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url");
// ── Простой лимитер по IP ──────────────────────────────────────────────────
// onLimited зовётся один раз за окно — на первом отказе, чтобы лог не забивало.
function makeLimiter(max: number, windowMs: number, onLimited?: (ip: string) => void) {
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;
}
e.n++;
return true;
};
}
export function createSub(deps: { uidb: Database.Database; servers: Servers; baseDir: string }) {
const { uidb, servers } = deps;
// ── Настройка из окружения ─────────────────────────────────────────────
const SUB_PORT = Number(process.env.SUB_PORT) || 0;
const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on";
// Публичный адрес панели для ссылок и endpoints. Без него — адрес локального сервера,
// а у панели без своего VPN — тот, по которому к ней пришли.
const SUB_HOST = (process.env.SUB_HOST ?? "").trim();
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
let signKey: crypto.KeyObject | null = null;
let signPub: Buffer | null = null;
try {
signKey = crypto.createPrivateKey(fs.readFileSync(file(process.env.SUB_SIGN_KEY_FILE, "sub_sign.key")));
signPub = rawPublicKey(crypto.createPublicKey(signKey));
} catch {
console.warn("sub: ключ подписи не найден — sen://-подписка отключена");
}
let tlsMaterial: { key: Buffer; cert: Buffer } | null = null;
let tlsPin: Buffer | null = null;
if (SUB_TLS) {
try {
tlsMaterial = {
key: fs.readFileSync(file(process.env.SUB_TLS_KEY_FILE, "sub_tls.key")),
cert: fs.readFileSync(file(process.env.SUB_TLS_CERT_FILE, "sub_tls.crt")),
};
tlsPin = spkiPin(tlsMaterial.cert.toString());
} catch {
console.warn("sub: SUB_TLS=on, но сертификат не найден — подписка отключена");
signKey = null;
}
}
// ── Схема ──────────────────────────────────────────────────────────────
uidb.exec(`
CREATE TABLE IF NOT EXISTS master_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
secret BLOB NOT NULL UNIQUE,
device_limit INTEGER NOT NULL DEFAULT ${DEFAULT_LIMIT},
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
revoked_at INTEGER
);
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
device_name TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
pub_key TEXT NOT NULL UNIQUE,
auth_pub TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_seen INTEGER,
rekey_requested INTEGER NOT NULL DEFAULT 0,
UNIQUE (master_id, device_id)
);
-- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и
-- дальше не читается (кроме переноса ниже).
CREATE TABLE IF NOT EXISTS master_servers (
master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
PRIMARY KEY (master_id, server_id)
);
-- Пир устройства на сервере. peer_pub — ключ, который сейчас стоит на сервере
-- (NULL — ещё не добавлен); расходится с devices.pub_key после смены ключа, пока
-- сервер не догнали. ip/psk — как их выдал awg-ctrl этого сервера.
CREATE TABLE IF NOT EXISTS device_peers (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
peer_pub TEXT,
ip TEXT,
psk TEXT,
psk_pending INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (device_id, server_id)
);
-- Пиры, которые надо удалить с сервера, когда до него дотянемся.
CREATE TABLE IF NOT EXISTS peer_tombstones (
server_id INTEGER NOT NULL,
pub_key TEXT NOT NULL,
PRIMARY KEY (server_id, pub_key)
);
-- Последний известный профиль сервера: config собирается из него, даже когда
-- нода не в сети, — иначе сервер пропадал бы из подписки на время обрыва.
CREATE TABLE IF NOT EXISTS server_profiles (
server_id INTEGER PRIMARY KEY,
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, а пир устройства
// уже стоит там под devices.pub_key (ip/psk подтянет reconcile). У ключа всегда есть хотя
// бы один сервер, поэтому «ни одной строки» бывает только у ключей из старой схемы.
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);
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"),
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 = ?"),
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 = ?"),
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 = ?"),
usedServers: uidb.prepare("SELECT server_id FROM master_servers UNION SELECT server_id FROM peer_tombstones"),
devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"),
deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"),
deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_id = ?"),
deviceCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM devices WHERE master_id = ?"),
deviceInsert: uidb.prepare<[number, string, string, string, string, string, number, string]>(
"INSERT INTO devices (master_id, device_id, device_name, platform, pub_key, auth_pub, created_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"),
deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"),
deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? WHERE id = ?"),
devicePub: uidb.prepare<[string, number, number]>("UPDATE devices SET pub_key = ?, rekey_requested = ? WHERE id = ?"),
deviceFlag: uidb.prepare<[number, number]>("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?"),
linksOf: uidb.prepare<[number]>("SELECT * FROM device_peers WHERE device_id = ? ORDER BY server_id"),
linkAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)"),
linkDel: uidb.prepare<[number, number]>("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?"),
linkSet: uidb.prepare<[string | null, string | null, string | null, number, number, number]>(
"UPDATE device_peers SET peer_pub = ?, ip = ?, psk = ?, psk_pending = ? WHERE device_id = ? AND server_id = ?"),
linkPskDue: uidb.prepare<[number]>("UPDATE device_peers SET psk_pending = 1 WHERE device_id = ? AND peer_pub IS NOT NULL"),
// Что на сервере расходится с нужным: пира нет, стоит старый ключ, не знаем ip/psk, ждёт новый PSK.
linksToFix: uidb.prepare<[number]>(`
SELECT l.*, d.pub_key AS dev_pub, d.master_id FROM device_peers l JOIN devices d ON d.id = l.device_id
WHERE l.server_id = ? AND (l.peer_pub IS NULL OR l.peer_pub <> d.pub_key
OR l.ip IS NULL OR l.psk IS NULL OR l.psk_pending = 1)`),
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"),
};
const now = () => Math.floor(Date.now() / 1000);
const hasCtrlOk = (s: number) => s >= 200 && s < 300;
const serverIdsOf = (masterId: number) => (q.serversOf.all(masterId) as { server_id: number }[]).map(r => r.server_id);
// ── Сведение серверов ──────────────────────────────────────────────────
function storedProfile(id: number): Profile | null {
const row = q.profileGet.get(id) as { profile: string } | undefined;
try { return row ? JSON.parse(row.profile) as Profile : null; } catch { return null; }
}
async function refreshProfile(id: number, ctrl: Ctrl): Promise<boolean> {
const r = await ctrl("GET", "/api/profile");
if (!hasCtrlOk(r.status) || typeof r.data?.endpoint !== "string") return false;
const json = JSON.stringify(r.data);
const row = q.profileGet.get(id) as { profile: string } | undefined;
if (row?.profile !== json) q.profileSet.run(id, json, now());
return true;
}
// Один проход на сервер за раз: параллельные проходы добавили бы одного пира дважды.
const locks = new Map<number, Promise<void>>();
function reconcile(id: number): Promise<void> {
const next = (locks.get(id) ?? Promise.resolve()).then(() => reconcileNow(id)).catch(e => {
console.warn(`sub: сервер #${id} не сведён: ${(e as Error).message}`);
});
locks.set(id, next);
return next;
}
const reconcileAll = (ids: Iterable<number>) => Promise.all([...new Set(ids)].map(reconcile));
async function reconcileNow(id: number) {
if (!servers.exists(id) || !servers.isOnline(id)) return;
const ctrl = servers.ctrlFor(id);
if (!await refreshProfile(id, ctrl)) return; // сервер не отвечает — попробуем позже
// Список пиров сервера — лениво, только если понадобится (409, потерянный ip/psk).
let listed: Map<string, { ip: string; psk_key: string }> | null = null;
const onServer = async (pub: string) => {
if (!listed) {
const r = await ctrl("GET", "/api/peers");
if (!hasCtrlOk(r.status)) throw new Error(`peers ${r.status}`);
listed = new Map((r.data.peers as { pub_key: string; ip: string; psk_key: string }[]).map(p => [p.pub_key, p]));
}
return listed.get(pub) ?? null;
};
const gone = (s: number) => s === 502 || s === 504; // связь пропала посреди прохода
for (const { pub_key } of q.tombsOf.all(id) as { pub_key: string }[]) {
const r = await ctrl("DELETE", `/api/peers/${pubParam(pub_key)}`);
if (hasCtrlOk(r.status) || r.status === 404) q.tombDel.run(id, pub_key);
else if (gone(r.status)) return;
}
type Peer = { ip: string; psk_key: string };
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}` });
if (hasCtrlOk(r.status)) return r.data;
if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся
return gone(r.status) ? "gone" : null;
};
let peer: Peer | null | "gone";
if (l.peer_pub === null) {
peer = await add();
} else if (l.peer_pub !== l.dev_pub) {
// Ключ сменили, пока сервер был недоступен: тот же IP и PSK, новый ключ.
const r = await ctrl("PUT", `/api/peers/${pubParam(l.peer_pub)}`, { pub_key: l.dev_pub });
peer = hasCtrlOk(r.status) ? r.data
: r.status === 404 ? await add() // старого пира на сервере уже нет
: r.status === 409 ? await onServer(l.dev_pub)
: gone(r.status) ? "gone" : null;
} else {
peer = await onServer(l.dev_pub) ?? await add(); // ip/psk не знаем или пир пропал с сервера
}
if (peer === "gone") return;
if (!peer) continue;
let pending = 0;
if (l.psk_pending === 1) {
const r = await ctrl("POST", `/api/peers/${pubParam(l.dev_pub)}/psk`);
if (hasCtrlOk(r.status)) peer = r.data as Peer;
else if (gone(r.status)) return;
else pending = 1; // пир на месте, PSK сменим позже
}
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();
}
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
function forgetDevice(dev: DeviceRow): number[] {
const links = q.linksOf.all(dev.id) as LinkRow[];
uidb.transaction(() => {
for (const l of links) if (l.peer_pub) q.tombAdd.run(l.server_id, l.peer_pub);
q.deviceDelete.run(dev.id); // device_peers уйдут каскадом
})();
return links.map(l => l.server_id);
}
// ── config ─────────────────────────────────────────────────────────────
const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":"));
function subHost(req?: Request): string {
if (SUB_HOST) return SUB_HOST;
const local = servers.exists(0) ? storedProfile(0) : null;
if (local) return hostOf(local.endpoint);
return (req?.hostname ?? "").replace(/^\[|\]$/g, "");
}
// В servers[] — только серверы, где уже стоит текущий ключ устройства и известны ip/psk.
// Сервер, который ещё не догнали (нода не в сети при регистрации или смене ключа), появится,
// когда его сведут, — rev при этом сменится, и клиент подтянет.
function buildConfig(dev: DeviceRow, req?: Request) {
const list = [];
for (const l of q.linksOf.all(dev.id) as LinkRow[]) {
const prof = storedProfile(l.server_id);
if (!prof || l.peer_pub !== dev.pub_key || !l.ip || !l.psk) continue;
list.push({
id: l.server_id, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub,
psk: l.psk, address: `${l.ip}/32`, dns: prof.dns,
keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg,
});
}
const cfg = {
rekey: dev.rekey_requested === 1,
endpoints: [`${subHost(req)}:${SUB_PORT}`],
servers: list,
};
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
return { rev, ...cfg };
}
// Конфиг, в котором есть хотя бы один сервер. Пустой не отдаём: клиент снёс бы туннель
// из-за временной недоступности нод — пусть лучше живёт со старым конфигом.
async function readyConfig(dev: DeviceRow, req: Request) {
let cfg = buildConfig(dev, req);
if (cfg.servers.length === 0) {
await reconcileAll((q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id));
cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req);
}
if (cfg.servers.length === 0) throw new Error("no servers");
return cfg;
}
// ── Публичная часть: /sub/v1 ───────────────────────────────────────────
const sub = express();
sub.disable("x-powered-by");
sub.use(express.json({
limit: "4kb",
verify: (req, _res, buf) => { (req as any).rawBody = buf; },
}));
const 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 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));
}
// Проверка подписи запроса + защита от повторов. auth_pub — 32 байта.
// null — всё верно, иначе причина отказа (для лога).
function signFailure(req: Request, authPub: Buffer): string | null {
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()} с)`;
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";
const t = Date.now();
for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k);
if (seenSigs.has(sig)) return "replay";
seenSigs.set(sig, t + 2 * TS_WINDOW * 1000);
return null;
}
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) {
(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" });
}
const wrap = (fn: (req: Request, res: Response) => Promise<void>) =>
(req: Request, res: Response) => { fn(req, res).catch(() => { send(req, 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 b = (req.body ?? {}) as Record<string, unknown>;
const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null;
const secretB = str(b.sub, /^[A-Za-z0-9_-]{22}$/);
const deviceId = str(b.device_id, /^[\w.-]{1,64}$/);
const pubKey = str(b.pub_key, B64_PUB);
const authPubS = str(b.auth_pub, /^[A-Za-z0-9_-]{43}$/);
const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : "";
const platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : "";
const version = str(b.version, CLIENT_VERSION) ?? "";
if (!secretB || !deviceId || !pubKey || !authPubS) { send(req, 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;
}
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
if (!master) { send(req, 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; }
let deviceRowId = 0;
let touched: number[] = [];
try {
uidb.transaction(() => {
const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined;
if (existing) touched = forgetDevice(existing); // переустановка — старые пиры на удаление
else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit)
throw Object.assign(new Error("limit"), { code: "limit" });
deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid);
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 уже заняты
}
await reconcileAll([...touched, ...serverIds]);
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
const cfg = buildConfig(dev, req);
if (cfg.servers.length === 0) {
// Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже.
await reconcileAll(forgetDevice(dev));
send(req, res, 502, { error: "unavailable" });
return;
}
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
// без отдельного запроса списка.
send(req, res, 201, {
device: dev.id, config: cfg, key_uuid: master.uuid,
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
});
}));
// Сколько мест у ключа занято — клиент показывает это, пока ссылку только вставили и ещё ничего не
// регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register,
// а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать.
sub.post("/sub/v1/peek", wrap(async (req, res) => {
const ip = ipOf(req);
const secretB = typeof (req.body as any)?.sub === "string" && /^[A-Za-z0-9_-]{22}$/.test((req.body as any).sub) ? (req.body as any).sub as string : null;
const 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" });
return;
}
send(req, res, 200, { key_uuid: master.uuid, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
}));
sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
q.deviceSeen.run(now(), dev.id);
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
const v = String(req.headers["x-sen-version"] ?? "");
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
send(req, res, 200, { config: await readyConfig(dev, req) });
}));
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
// можно лишь себя (DELETE /sub/v1/device); чужие устройства убирает панель.
sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
const master = q.masterById.get(dev.master_id) as MasterRow | undefined;
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
send(req, 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,
created_at: d.created_at, last_seen: d.last_seen, current: d.id === dev.id,
})),
});
}));
// Новый ключ ставится на все серверы ключа: где сервер доступен — сразу (тот же IP и PSK),
// где нет — когда он появится. Если не принял ни один — ключ не меняем, клиент повторит.
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; }
try { q.devicePub.run(pub, 0, dev.id); }
catch { send(req, res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id);
await reconcileAll(ids);
const cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req);
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" });
return;
}
send(req, 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 });
}));
sub.use((req, res) => { send(req, res, 404, { error: "not_found" }); });
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
sub.use((_err: unknown, req: Request, res: Response, _next: NextFunction) => { send(req, 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);
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-ключа");
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 listMasters = (scope: Scope) =>
(q.masters.all() as (MasterRow & { devices: number })[]).filter(m => visible(m.id, scope)).map(masterOut);
// Серверы по умолчанию: у панели — сервер по умолчанию, у API-ключа — вся его область.
function createMaster(body: unknown, scope: Scope) {
const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown };
if (typeof label !== "string" || !LABEL.test(label)) fail(400, "Метка: до 40 символов, без переводов строк и <>");
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
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; }
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);
for (const sid of ids) q.serverAdd.run(id, sid);
})();
return masterOutById(id);
}
async function updateMaster(m: MasterRow, body: unknown, scope: Scope) {
const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; 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");
let ids = serverIdsOf(m.id);
const affected: number[] = [];
if (wanted !== undefined) {
const next = parseServers(wanted, scope);
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(() => {
for (const sid of removed) {
q.serverDel.run(m.id, sid);
for (const d of devices) {
const l = (q.linksOf.all(d.id) as LinkRow[]).find(x => x.server_id === sid);
if (l?.peer_pub) q.tombAdd.run(sid, l.peer_pub);
q.linkDel.run(d.id, sid);
}
}
for (const sid of added) {
q.serverAdd.run(m.id, sid);
for (const d of devices) q.linkAdd.run(d.id, sid);
}
})();
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);
}
}
await reconcileAll(affected);
return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids, unbound };
}
// Новая ссылка; устройства продолжают работать: у них 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); };
// Ключ сразу перестаёт работать (ссылка, регистрация, устройства), но строка остаётся
// отозванной, пока каждый его сервер не подтвердит, что снял пиров, — иначе панель
// сказала бы «удалён», пока на офлайн-ноде устройства ещё подключаются.
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);
})();
await reconcileAll(affected);
return { success: true, id: m.id, pending: serverIdsOf(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);
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,
});
return { link, tls: tlsPin !== null };
}
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
async function masterDevices(m: MasterRow) {
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");
if (!hasCtrlOk(r.status)) return;
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number }[]) {
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0 };
stats.set(p.pub_key, {
online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake),
rx: s.rx + p.rx, tx: s.tx + p.tx,
});
}
}));
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,
};
});
}
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));
r.get("/", aw((req, res) => { res.json({ keys: listMasters(scopeOf(req)) }); }));
r.post("/", aw((req, res) => {
const problem = linkProblem(req); // без ссылки ключ внешней программе бесполезен
if (problem) fail(503, problem);
const out = createMaster(req.body, scopeOf(req));
res.status(201).json({ ...out, ...linkOf(masterFor(out.id, null), req) });
}));
r.get("/:id", aw(async (req, res) => {
const m = master(req);
// devices — счётчик, как в списке; сами устройства — в device_list.
res.json({ ...masterOutById(m.id), ...(linkProblem(req) ? { link: null, tls: tlsPin !== null } : linkOf(m, req)), device_list: await masterDevices(m) });
}));
r.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(master(req), req.body, scopeOf(req))); }));
r.post("/:id/rotate", aw((req, res) => {
const m = master(req);
rotateMaster(m);
res.json({ id: m.id, ...linkOf(masterFor(m.id, null), req) });
}));
r.post("/:id/rekey", aw((req, res) => { const m = master(req); rekeyMaster(m); res.json({ id: m.id }); }));
r.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(master(req))); }));
r.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(master(req)) }); }));
r.delete("/:id/devices/:device", aw(async (req, res) => { res.json(await deleteDevice(device(req))); }));
r.post("/:id/devices/:device/rekey", aw((req, res) => { const d = device(req); rekeyDevice(d); res.json({ id: d.id }); }));
r.post("/:id/devices/:device/psk", aw(async (req, res) => { res.json(await rotatePsk(device(req))); }));
return r;
}
// ── Листенер и фоновое сведение ────────────────────────────────────────
servers.onOnline(id => { void reconcile(id); });
// Сервер удаляют из реестра: пока им пользуется живой мастер-ключ — нельзя. Иначе сперва
// сводим его (ключи его не используют, значит, список пуст и пиры панели снимутся) — после
// удаления до него уже не дотянуться. Не вышло (нода не в сети) — удалять только с 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(() => {
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
}, SYNC_EVERY).unref();
if (!signKey) return;
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
server.listen(SUB_PORT, () => {
console.log(`sub: listening on :${SUB_PORT} (${tlsMaterial ? "https" : "http"})`);
});
}
return { masterKeys, devices, apiRouter, start };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.2.2", "version": "0.3.4",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+264 -7
View File
@@ -1,7 +1,7 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { spawn } from "child_process"; import { spawn, spawnSync } from "child_process";
import { readFileSync, writeFileSync, existsSync, import { readFileSync, writeFileSync, existsSync,
mkdirSync, openSync, closeSync, mkdirSync, openSync, closeSync,
unlinkSync } from "fs"; unlinkSync } from "fs";
@@ -93,14 +93,64 @@ const SERVICES = {
// ui.db (ключи внешнего API) в каталоге данных AWG (вне PROJECT) — // ui.db (ключи внешнего API) в каталоге данных AWG (вне PROJECT) —
// переживает переустановку. // переживает переустановку.
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db", UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
// Подписка sen:// (мастер-ключи SenAWG): порт, режим TLS, ключ подписи и
// сертификат. Без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
SUB_PORT: process.env.SUB_PORT ?? "",
SUB_TLS: process.env.SUB_TLS ?? "off",
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
// Публичный адрес панели в sen://-ссылках и endpoints. Пусто — адрес локального
// сервера (у панели без своего VPN — тот, по которому к ней зашли).
SUB_HOST: process.env.SUB_HOST ?? "",
// Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
// core без собственного VPN: awg-ctrl рядом не нужен.
NODE_PORT: process.env.NODE_PORT ?? "",
LOCAL_NODE: process.env.LOCAL_NODE ?? "on",
NODE_TLS_KEY_FILE: process.env.NODE_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/node_tls.key",
NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
}, },
pidFile: "/tmp/awg-ui.pid", pidFile: "/tmp/awg-ui.pid",
logFile: path.join(LOGS, "awg-ui.log"), logFile: path.join(LOGS, "awg-ui.log"),
}, },
// Агент ноды: сам звонит в core (CORE_HOST/PORT/PIN из join-строки) и исполняет запросы
// на локальном awg-ctrl. Запускается вместо awg-ui — см. ROLE.
agent: {
label: "awg-agent",
cwd: path.join(ROOT, "awg-agent"),
entry: "index.ts",
env: {
CORE_HOST: process.env.CORE_HOST ?? "",
CORE_PORT: process.env.CORE_PORT ?? "",
CORE_PIN: process.env.CORE_PIN ?? "",
NODE_ID: process.env.NODE_ID ?? "",
NODE_KEY_FILE: process.env.NODE_KEY_FILE ?? "/etc/amnezia/amneziawg/node.key",
JOIN_SECRET: process.env.JOIN_SECRET ?? "",
CLI_ENV_FILE: envFile,
AWGCTRL_PORT: process.env.AWGCTRL_PORT ?? "3005",
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
},
pidFile: "/tmp/awg-agent.pid",
logFile: path.join(LOGS, "awg-agent.log"),
},
} as const; } as const;
type SvcName = keyof typeof SERVICES; type SvcName = keyof typeof SERVICES;
const ALL: SvcName[] = ["awgctrl", "ui"];
// Роль установки. node — VPN-сервер без панели: awg-ctrl + агент, который сам
// подключается к core (есть CORE_HOST, его пишет `join`). core с LOCAL_NODE=off —
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
const IS_NODE = !!process.env.CORE_HOST;
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
// Нода со своей панелью: работающий сервер «панель + VPN» подключили к главной панели, не
// выключая его собственную (join без --no-panel). Обе панели ходят в один awg-ctrl — он и
// есть источник правды, поэтому друг другу они не мешают. У ноды, поставленной install.sh,
// панели нет (UI_PASS не задан).
const NODE_PANEL = IS_NODE && !!process.env.UI_PASS && (process.env.NODE_PANEL ?? "on").toLowerCase() !== "off";
const ALL: SvcName[] = IS_NODE ? (NODE_PANEL ? ["awgctrl", "ui", "agent"] : ["awgctrl", "agent"])
: NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
function readPid(name: SvcName): number | null { function readPid(name: SvcName): number | null {
const { pidFile } = SERVICES[name]; const { pidFile } = SERVICES[name];
@@ -263,7 +313,7 @@ function probe(url: string, timeoutMs = 1000): Promise<boolean> {
// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например, // Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например,
// ensureInterfaceUp() бросил исключение) — сразу выходим. // ensureInterfaceUp() бросил исключение) — сразу выходим.
async function waitForHealth(name: SvcName, attempts = 10): Promise<boolean> { async function waitForHealth(name: "awgctrl", attempts = 10): Promise<boolean> {
const url = `http://127.0.0.1:${SERVICES[name].env.PORT}/health`; const url = `http://127.0.0.1:${SERVICES[name].env.PORT}/health`;
for (let i = 0; i < attempts; i++) { for (let i = 0; i < attempts; i++) {
if (!isRunning(name)) return false; if (!isRunning(name)) return false;
@@ -274,12 +324,16 @@ async function waitForHealth(name: SvcName, attempts = 10): Promise<boolean> {
} }
async function startAll() { async function startAll() {
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
await start("awgctrl"); await start("awgctrl");
const rest = ALL.filter(n => n !== "awgctrl");
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) { if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
console.error(` ${grey("■")} awg-ctrl не отвечает — awg-ui не запущен (см. ${SERVICES.awgctrl.logFile})`); console.error(` ${grey("■")} awg-ctrl не отвечает — ${rest.map(n => SERVICES[n].label).join(", ")} не запущен (см. ${SERVICES.awgctrl.logFile})`);
return; return;
} }
await start("ui"); for (const n of rest) await start(n);
} }
async function stopAll() { for (const n of ALL) await stop(n); } async function stopAll() { for (const n of ALL) await stop(n); }
@@ -292,6 +346,206 @@ function updateEnvVar(content: string, key: string, value: string): string {
: content.trimEnd() + `\n${key}=${value}\n`; : content.trimEnd() + `\n${key}=${value}\n`;
} }
function removeEnvVar(content: string, key: string): string {
return content.split("\n").filter(l => !l.startsWith(`${key}=`)).join("\n");
}
// Роль читается из окружения при старте процесса, поэтому после смены cli.env
// перезапуск делаем свежим процессом CLI, а не в этом же.
function restartFresh(): void {
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
}
// `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как ноду. Рядом
// запускается агент; awg-ctrl и пользователи остаются как были. Своя панель (awg-ui) продолжает
// работать, если она здесь есть, — с --no-panel она останавливается.
async function joinCore(link?: string, flag?: string) {
if (!link?.startsWith("awgjoin://") || (flag !== undefined && flag !== "--no-panel")) {
console.error(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)");
process.exit(1);
}
let j: { v?: number; h?: string; p?: number; pin?: string; n?: number; s?: string };
try { j = JSON.parse(Buffer.from(link.slice("awgjoin://".length), "base64url").toString("utf8")); }
catch { console.error(" Ссылка повреждена"); process.exit(1); }
const ok = j.v === 1 && typeof j.h === "string" && /^[A-Za-z0-9.:_-]{1,253}$/.test(j.h)
&& Number.isInteger(j.p) && j.p! > 0 && j.p! < 65536
&& typeof j.pin === "string" && /^[A-Za-z0-9_-]{43}$/.test(j.pin)
&& Number.isInteger(j.n) && j.n! > 0 && typeof j.s === "string" && /^[A-Za-z0-9_-]{22}$/.test(j.s);
if (!ok) { console.error(" Ссылка неверного формата или от другой версии панели"); process.exit(1); }
if (IS_NODE && !await confirmYes(` Эта нода уже подключена к ${process.env.CORE_HOST}. Перепривязать?`)) return;
const keepPanel = flag !== "--no-panel" && !!process.env.UI_PASS;
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
const set: Record<string, string> = {
CORE_HOST: j.h!, CORE_PORT: String(j.p), CORE_PIN: j.pin!, NODE_ID: String(j.n), JOIN_SECRET: j.s!,
NODE_PANEL: keepPanel ? "on" : "off",
};
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
writeFileSync(envFile, content, { mode: 0o600 });
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
const dropPeers = !keepPanel && process.env.UI_PASS ? await askDropPanelPeers() : false;
if (!keepPanel) await stop("ui");
if (dropPeers) await dropArea(localPanelId()!);
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
restartFresh();
}
// ── Пиры подписки (sen://) на локальном awg-ctrl ──────────────────────────────
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели.
// У ноды со своей панелью на одном awg-ctrl две области (своя и core). Пиры без области
// («m1/d2») выданы до областей. Снимает их awg-ctrl по полному списку от панели
// (PUT /api/peers/sync); пустой список = снять всю область. Здесь — ручной рычаг для
// оператора: панель выключена или core пропал насовсем. По времени ничего не удаляется.
const PANEL_ID_FILE = path.join(ROOT, "awg-ui", "panel.id");
const AREA_RE = /^[a-z0-9]{8,16}$/;
const LEGACY_OWNER = /^m\d+\/d\d+$/;
function localPanelId(): string | null {
try { const id = readFileSync(PANEL_ID_FILE, "utf8").trim(); return AREA_RE.test(id) ? id : null; }
catch { return null; }
}
// Тот же короткоживущий токен, что awg-ui и awg-agent шлют awg-ctrl (Ed25519, 60 с).
function mintInternalToken(): string {
const key = crypto.createPrivateKey(readFileSync(SERVICES.ui.env.INTERNAL_AUTH_KEY_FILE));
const t = Math.floor(Date.now() / 1000);
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
const p = Buffer.from(JSON.stringify({ iss: "awg-cli", iat: t, exp: t + 60 })).toString("base64url");
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), key).toString("base64url")}`;
}
function ctrlCall(method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const req = http.request({
host: "127.0.0.1", port: Number(SERVICES.awgctrl.env.PORT), method, path: urlPath, timeout: 30_000,
headers: {
Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json",
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
},
}, res => {
let raw = "";
res.on("data", c => { raw += c; });
res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, data: null }); } });
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("timeout")));
req.end(payload);
});
}
interface CtrlPeer { pub_key: string; ip: string; owner: string; online: boolean }
async function listPeers(): Promise<CtrlPeer[]> {
const r = await ctrlCall("GET", "/api/peers");
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
return r.data.peers as CtrlPeer[];
}
// Область пира: id панели, «legacy» (до областей) или null (чужой формат — не наш).
function areaOf(owner: string): string | null {
const head = owner.split("/")[0];
if (owner.includes("/") && AREA_RE.test(head)) return head;
return LEGACY_OWNER.test(owner) ? "legacy" : null;
}
const peersWord = (n: number) =>
n % 10 === 1 && n % 100 !== 11 ? "пир" : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? "пира" : "пиров";
async function askDropPanelPeers(): Promise<boolean> {
const own = localPanelId();
if (!own) return false;
let n: number;
try { n = (await listPeers()).filter(p => areaOf(p.owner) === own).length; }
catch (e) { console.warn(` ${dim(`Не удалось посмотреть пиров подписки: ${(e as Error).message}`)}`); return false; }
if (n === 0) return false;
const preset = (process.env.NODE_PEERS ?? "").toLowerCase();
if (preset === "keep" || preset === "drop") return preset === "drop";
console.log(`\n На этой панели ${n} ${peersWord(n)} подписки (устройства мастер-ключей). Без панели ими некому управлять:`);
console.log(` 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin`);
console.log(` 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)`);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const a = await new Promise<string>(r => rl.question(" Выбор [1]: ", r));
rl.close();
return a.trim() === "2";
}
// Снять всю область: для панели — пустым списком через sync, для legacy — по одному.
async function dropArea(area: string): Promise<void> {
try {
if (area === "legacy") {
const legacy = (await listPeers()).filter(p => areaOf(p.owner) === "legacy");
for (const p of legacy) await ctrlCall("DELETE", `/api/peers/${Buffer.from(p.pub_key, "base64").toString("base64url")}`);
console.log(` ${green("✓")} Снято пиров без области: ${legacy.length}`);
return;
}
const r = await ctrlCall("PUT", "/api/peers/sync", { owner: area, keep: [] });
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
console.log(` ${green("✓")} Снято пиров области ${area}: ${r.data.removed}`);
} catch (e) {
console.error(` Не удалось снять пиров: ${(e as Error).message}`);
}
}
async function peersCmd(sub?: string, arg?: string) {
const own = localPanelId();
let peers: CtrlPeer[];
try { peers = await listPeers(); }
catch (e) { console.error(` awg-ctrl недоступен: ${(e as Error).message}`); process.exit(1); }
const areas = new Map<string, CtrlPeer[]>();
for (const p of peers) { const a = areaOf(p.owner); if (a) areas.set(a, [...(areas.get(a) ?? []), p]); }
if (sub === undefined) {
if (!areas.size) { console.log(" Пиров подписки (sen://) на этом сервере нет"); return; }
console.log("\n Пиры подписки (sen://) на этом сервере:\n");
for (const [a, list] of areas) {
const mark = a === own ? "эта панель" : a === "legacy" ? "без области (выданы до областей)" : "";
console.log(` ${bold(a.padEnd(16))} ${String(list.length).padStart(4)} ${peersWord(list.length).padEnd(5)}` +
` онлайн ${list.filter(p => p.online).length} ${dim(mark)}`);
}
console.log(`\n ${dim("Снять область целиком: awg-ctrl peers drop <область>")}`);
return;
}
if (sub !== "drop" || !arg || !(arg === "legacy" || AREA_RE.test(arg))) {
console.error(" Usage: awg-ctrl peers [drop <область|legacy>]");
process.exit(1);
}
const n = areas.get(arg)?.length ?? 0;
if (!n) { console.log(` В области ${arg} пиров нет`); return; }
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
if (arg === own && isRunning("ui")) {
console.error(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней");
process.exit(1);
}
console.log(` ${dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")}`);
console.log(` ${dim("сперва удалите сервер в ней (или мастер-ключи).")}`);
if (!await confirmYes(` Снять ${n} ${peersWord(n)} области ${arg}? Их устройства потеряют доступ к этому серверу.`)) return;
await dropArea(arg);
}
async function unjoinCore() {
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
await stop("agent");
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"]) content = removeEnvVar(content, k);
writeFileSync(envFile, content, { mode: 0o600 });
console.log(` ${green("✓")} Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart`);
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
if (!process.env.UI_PASS)
console.log(` ${dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials")}`);
}
async function confirmYes(q: string): Promise<boolean> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const a = await new Promise<string>(r => rl.question(`${q} [y/N] `, r));
rl.close();
return /^y/i.test(a.trim());
}
async function setCredentials(field?: "user" | "pass") { async function setCredentials(field?: "user" | "pass") {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string) => new Promise<string>(r => rl.question(q, r)); const ask = (q: string) => new Promise<string>(r => rl.question(q, r));
@@ -386,9 +640,9 @@ async function main() {
return; return;
} }
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]>"; const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]>";
const [,,, sub] = process.argv; const [,,, sub, arg] = process.argv;
switch (cmd) { switch (cmd) {
case "start": await startAll(); break; case "start": await startAll(); break;
@@ -400,6 +654,9 @@ async function main() {
else if (sub === "pass") await setCredentials("pass"); else if (sub === "pass") await setCredentials("pass");
else await setCredentials(); else await setCredentials();
break; 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); default: console.log(USAGE); process.exit(1);
} }
} }
+907 -481
View File
File diff suppressed because it is too large Load Diff