53 Commits
Author SHA1 Message Date
maeneko 2b2ebdcfd0 Installer: ALLOW_048=y updates a 0.4.8 install without the wipe
ci / check (push) Failing after 11m57s
release / release (push) Successful in 19m38s
0.4.8 data migrates to 0.5.0, so the outdated-install gate can be skipped for
it on request. The red card mentions the flag when the old version is 0.4.8;
awg-ctrl passes its environment through, so ALLOW_048=y awg-ctrl works too.
2026-10-29 23:11:30 +03:00
maeneko 67204ddc25 Merge origin/main into the 0.5.0 redesign
ci / check (push) Failing after 6m27s
Brings in 0.4.8: connection log, uninstall and update from the awg-ctrl menu,
secret panel path, stealth hub and sen:// port, random I1/MTU on install.
install.sh keeps the new output (spinner, cards, outdated-install wipe) on top
of the reorganised installer; an outdated install can be reinstalled with a
wipe or removed. The connection log tab uses the MD3 segmented button; its
metrics-token scrape snippet moved to Settings.
2026-10-29 23:07:03 +03:00
maeneko 5b29993d5d Panel redesign, per-server I1 masking, node minimum version 0.5.0, installer output
ci / check (push) Successful in 23m54s
- UI: settings tab with sections (server, API keys, Prometheus, audit, migrate,
  about); server settings moved there; master keys table rework (menu, chips,
  version, compact phone cards); three-state node version chip
- I1: random per-server template (DNS / QUIC Initial / STUN) kept in users.db,
  GET/POST /api/masking, change from the master keys banner
- Hub rejects nodes older than 0.5.0 (close 4026), /ui/nodes reports outdated
- install.sh: spinner for long commands (full output in the log), step progress
  bar, cards; installs older than 0.5.0 require a full reinstall with data reset
- Bump to Beta 0.5.0
2026-10-29 22:59:09 +03:00
Ivan 17bd6b19a4 Merge pull request #1 from maeneko/claude/latest-release-changes-i2p2c0
Beta 0.4.8: защита от обнаружения, журнал подключений, установщик и CLI
2026-10-28 16:00:09 +03:00
Claude f0949c9136 Bump to Beta 0.4.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:57:21 +00:00
Claude 0a9eaa8c8e awg-ctrl: commands for the secret path, connection log, metrics, hub, check
The new features were only reachable from the web panel. awg-ctrl now has:

- panel [path new|off|<path>] — the panel address; a new random, own or no
  secret path (cli.env, panel restarted, VPN untouched);
- log [<search>] [-n N] and log seen — the connection log of this server and
  who was online when, with sen:// device and master key names from ui.db when
  the panel is on the same machine;
- metrics [token|revoke] — the metrics-only token for an external Prometheus,
  printed once with a ready prometheus.yml fragment that carries the path;
- hub [legacy on|off|auto] — nodes with and without the pre-upgrade signature
  and the HUB_LEGACY mode;
- check — what this server gives away: shared pre-randomization VPN values or
  no own I1, no secret path, plain-HTTP panel and subscription, the generic
  "sen"/"awg-node" certificate names, an open hub — each with the fix.

The interactive menu gets items 10–13: panel address and path, connection log,
who was online, and the check. Columns are padded by runes, not bytes, so
Cyrillic names line up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:55:39 +00:00
Claude ea07914249 awg-ctrl: update, reinstall and uninstall from the menu
The awg-ctrl menu only started, stopped and changed the panel login; updating
or removing an installation meant finding and running install.sh by hand. The
menu gets "Обновить", "Переустановить" and "Удалить" (7–9), and the same as
`awg-ctrl update | reinstall | uninstall`.

The work stays in install.sh, which now takes ACTION=update|reinstall|uninstall
to skip its own menu. The CLI fetches it — the latest from INSTALLER_URL
(GitHub by default) for update and reinstall, the copy shipped in the release
archive for uninstall, so removal works offline — writes it to a temp file and
execs bash on it in the same terminal. It must not run from /opt/awg-control or
via `bash -c`: kill_related kills every process whose command line holds the
install path, and would kill the installer itself. The CLI passes its original
environment, not the one with cli.env loaded, or the installer would take
UI_PORT, SERVER_NAME and the rest as explicit overrides. The installer deletes
its temp copy on start.

The release archive now includes install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:50:00 +00:00
Claude 1169a0adc7 Tidy up the installer
Layout: the file now reads top to bottom as output helpers, constants,
functions and the scenario. Functions were scattered between executable code
(some defined inside steps), the previous role was computed twice, and the
firewall code sat in the middle of a step. The firewall is now a function and
a step of its own; installing the awg-ctrl command moved into the services
step, so there are still six steps.

Fixes:
- the shebang was on line 4, after the license comment, so it did nothing;
- a reinstall removed /opt/awg-control before the configuration questions, so
  answering "no" to "Continue?" left the server with no installation; it is
  now removed only after the confirmation;
- with kept data the panel port was asked again with a new random default,
  which broke bookmarks and the API address even though the secret path was
  kept; the panel port, login, password and JWT secret now default to the
  previous values;
- UI_PORT, UI_USER, UI_PASS and SERVER_NAME from the environment were
  overwritten by the prompts; they now skip them, as the README says;
- when the user database existed but was not kept, the server name was not
  asked and silently became "VPN";
- random ports are checked to be free and distinct from each other;
- the server IP from ifconfig.me is validated, with a fallback to the source
  address of the default route;
- prompts are all in Russian; a panel with VPN can pick a random node port
  with "+";
- comments no longer point to docs/node-protocol.md, which is not in the repo;
- shellcheck -S warning is clean.

An update now gives a secret path to an install that has none and prints the
new panel address (UI_PREFIX=off, or an empty UI_PREFIX= in cli.env, keeps it
without one). The summary shows whether the panel has a secret path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:27:08 +00:00
Claude ff2d71600c Rewrite the README
Sections were added one feature at a time and ended up in arrival order,
with the stealth notes inside "Installation". The README now has a feature
summary and contents, an architecture section with the ports each server
opens, installation with roles and a table of installer variables, update /
reinstall / uninstall, a section on several servers and moving the panel
(it pointed to docs/node-protocol.md, which is not in the repo), one
"protection against detection" section covering randomized parameters, the
quiet ports and what they do not cover, the connection log, monitoring,
nginx, the API with the secret path in the base URL and the new connlog and
metrics routes, CLI commands and the main cli.env settings.

Also fixed on the way: the subscription is HTTPS by default, and a plain
update does not add a secret path to an older install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:13:40 +00:00
Claude 1a54d05cca VPN connection log: who connected and when
WireGuard has no connect events, only the last handshake and byte counters.
awg-ctrl now checks them every 30 s and writes conn_log in users.db: connect
at the first handshake, disconnect at the last activity (the last tick the
counters moved), with the session length and traffic, plus peer_seen, the
last time each peer was online. Open sessions are restored from the database,
so a restart adds no duplicates; a down interface is not a disconnect; a peer
removed mid-session is closed. Events keep the name, owner and VPN IP as of
that moment and live 90 days. Client public IPs are not recorded.

GET /api/connlog serves the events and the per-peer last-seen list. The panel
fetches it from the selected server at /ui/connlog and fills in master key
labels and device names for sen:// peers. A new "Подключения" tab shows the
event feed with search and paging, and "who was online when", including
those who never connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 12:06:40 +00:00
Claude fd5e2cc761 Hub: node signature before the WebSocket upgrade
The hub accepted a WebSocket from anyone on /node/v1, a path in the open
code, and replied with a hello nonce: one connection was enough to tell a
scanner what it had found. The agent now signs the upgrade request itself
(X-Node-Id, X-Node-Ts, X-Node-Sig; on first binding also the join secret
and its key), and the hub checks it before Accept. Without a valid
signature it answers Go's plain "404 page not found". Inside the socket
the protocol is unchanged, and the auth frame must come from the node that
signed the request.

Older agents send no signature. The hub lets them in while some node has
never connected the new way (nodes.preauth), logs them as outdated, and
closes that door by itself once every node is updated; HUB_LEGACY=on/off
overrides. The timestamp window is a day: node clocks drift, and the
headers travel inside pinned TLS. The agent treats a 404 at upgrade as a
rejection and backs off for a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:58:06 +00:00
Claude a3d4664214 Stealth sen:// port, random cert names, external Prometheus card
The subscription port answered every request with signed JSON: one probe
told a scanner what it had found, and the public key from any sen:// link
pinned the exact server. It now speaks the protocol only to those who prove
a secret — peek and register with the link's master secret, everything else
with a valid device signature — and gives Go's plain "404 page not found"
to the rest. A deleted device still gets its 410, but only when the request
is signed with its key, kept in the new revoked_devices table. The migration
freeze answers only proven clients too.

Self-signed certs for the subscription (SUB_TLS=on) and the node hub got
fixed names (sen, awg-node); new ones get a random host-like name. Clients
and nodes pin the key, so existing certs keep working.

The panel could already issue a metrics-only token, but nothing in the UI
used it. The Monitor tab gets an "External Prometheus" card: issue, reissue
and revoke the token, with a ready prometheus.yml fragment whose address is
taken from the page, so it carries the secret path or the nginx subfolder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:52:55 +00:00
Claude 619af21085 Full uninstall from the installer
On a server with an existing installation the menu gets a third choice,
«Удалить»; UNINSTALL=y runs it without the menu. After typing «удалить» it
offers a backup of /etc/amnezia and cli.env to /root, then removes the
services and the systemd unit, brings awg1 down, drops the shaping and
torrent nft tables, closes the ports it had opened in UFW, firewalld or
iptables, removes the Prometheus that `awg-ctrl monitor install` set up
(only ours), and deletes /opt/awg-control, /etc/amnezia, the awg-ctrl
command, the sysctl file and the install logs, which hold the panel
password. AmneziaWG packages, module and PPA go only on a separate yes.

kill_related moved above the menu so the uninstall can use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:45:48 +00:00
Claude 13da9a7200 Secret URL path for the panel
The panel port is open to the internet, and its login page and JS bundle
gave it away to any scanner in one GET. The installer now generates a
random UI_PREFIX: from outside the panel answers only under
http://IP:PORT/<prefix>/ and gives Go's plain "404 page not found" to
everything else. Loopback needs no prefix (CLI, local Prometheus, nginx on
the same host); while a move code is out, POST /migrate/v1/import is let
through without it, since the code carries no path.

A reinstall with kept data keeps the prefix; UI_PREFIX=off or a custom value
can be set before running the installer. A moved panel brings its prefix in
UI_PREFIX_MOVED, so old bookmarks and API clients keep working through the
relay. awg-ctrl status and credentials show the full panel address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:41:09 +00:00
Claude aa848de31c Hide the brand from unauthenticated requests
GET /ui/brand answered without auth with {"brand":"Forgetting",…}: one
request was enough for a scanner to tell a found port is this panel. It now
requires the panel JWT (Go and the Node reference alike). The login screen
says just «Вход», the page title is neutral until login, and the wordmark
loads after sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:30:40 +00:00
Claude 63e84e0df7 Random I1 and MTU on install
I1 becomes a DNS query to a random popular domain (A/AAAA) instead of the
shared icloud.com response every key carried. The format sticks to tags both
the kernel module and amneziawg-go accept and is checked before awg1.conf is
written: a bad I1 fails the whole awg-quick up. With KEEP_DATA=y the old I1
is kept, or generated if missing — the server never checks it, old keys keep
their own.

MTU is picked from 1320–1376 (AWG_MTU overrides) and kept on reinstall.
awg-ctrl now reads MTU from awg1.conf for vpn:// keys instead of a constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:26:07 +00:00
maeneko c7d1333125 Bump to Beta 0.4.7
ci / check (push) Successful in 34m44s
release / release (push) Successful in 23m0s
2026-10-27 21:52:55 +03:00
maeneko aa2a53808a Random AWG port and obfuscation params on install; open ports in firewalld/iptables 2026-10-27 21:52:51 +03:00
maeneko eb9fd700cb Bump to Beta 0.4.6
ci / check (push) Successful in 27m15s
release / release (push) Successful in 19m57s
2026-10-21 19:26:55 +03:00
maeneko 96c21408e6 Prometheus on a node; panel under a URL prefix; open ports on migrate import
A panel without VPN (e.g. after the move) can use a Prometheus that lives on
a node, such as the one with history left on the old server (node #0). Both
directions go through the agent's WSS: the node's Prometheus scrapes
127.0.0.1:AGENT_METRICS_PORT/metrics, the agent checks its token and asks
core (metrics → metrics_res, the same exposition as /metrics, at most once
per 5 s per connection); the monitor tab reads it back through the agent,
which forwards only GET /-/ready and /api/v1/query[_range]. The panel finds
it by itself (local first, then the lowest-id node), and the install/remove
buttons work on a panel without VPN. `monitor install` on a node without its
own panel picks AGENT_METRICS_PORT, writes it to cli.env and restarts the
agent; on the old server it keeps the collected data.

The panel works behind nginx in a subfolder: relative asset and API paths,
relative directory redirects, and the client address from X-Forwarded-For /
X-Real-IP only from trusted proxies (loopback, TRUST_PROXY).

migrate import opens the moved SUB_PORT/NODE_PORT in UFW when it is active.
2026-10-21 19:25:54 +03:00
maeneko 7ee126af30 README: install through sudo bash -c
ci / check (push) Successful in 25m56s
2026-10-21 17:29:04 +03:00
maeneko 45c96aa0b6 Bump to Beta 0.4.5
ci / check (push) Successful in 24m11s
release / release (push) Successful in 18m26s
2026-10-21 15:59:41 +03:00
maeneko eb19cacf93 Move the panel to a server without VPN; servers for all master keys at once
The «Перенос» tab moves a panel + VPN server to a panel-only server without
users noticing: the new panel issues a one-time awgmove:// code, the old one
sends its ui.db, subscription keys and hub certificate encrypted with it, and
becomes node #0 of the new panel. Server id 0 is kept, so config servers[] is
byte-identical. The old server relays its former ports (awg-relay, PROXY v1
header trusted only from RELAY_FROM), so sen:// links, nodes and the API keep
working. The same is available from the CLI: migrate export/import/cancel,
rehome.

Nodes now learn the panel's address: the hub sends it in ready, the agent
stores it in CORE_ENDPOINT, dials it first and confirms with core_ok. The
new panel shows when the old server can be turned off: devices that got the
new address, nodes that confirmed it, master keys never opened.

«Серверы для всех» on the master keys tab adds or removes servers on every
live key in one go, with a dry run that warns about API keys that would lose
sight of some keys, and sets the servers new keys get.
2026-10-21 15:59:33 +03:00
maeneko 0687650d26 Bump to Beta 0.4.4
ci / check (push) Successful in 24m29s
release / release (push) Successful in 19m48s
2026-10-20 20:39:09 +03:00
maeneko 3511b8c6c3 Master keys: a down server no longer breaks the device list, 403 for keys outside the API key's scope
masterDevices skips a server that did not answer and reports it as
unreachable; peers are fetched once per server per pass. The tab loads each
key on its own, counts devices from the key when the list is missing (and still
asks before unbinding), drops stale responses, labels an IP from another
server and blocks a double create. masterFor answers 403 instead of 404 for a
key that exists but is outside the API key's servers, so a client does not
mistake it for a deleted one. updateMaster checks every statement in its
transaction.
2026-10-20 20:39:09 +03:00
maeneko 28ccc17bde Bump to Beta 0.4.3
ci / check (push) Successful in 22m56s
release / release (push) Successful in 17m49s
2026-10-19 12:23:21 +03:00
maeneko d6fc2049c3 Master keys tab: same columns as users, device IPs, per-key summary
Status, priority/torrent chips and IP columns like the users tab; the API
returns each device's IP per server. The key row sums its devices (online,
priority, traffic); key UUID and device ID sit under the name, copy on click.
2026-10-19 12:23:21 +03:00
maeneko ea91265674 Bump to Beta 0.4.2.1
ci / check (push) Successful in 22m38s
release / release (push) Successful in 17m42s
2026-10-18 00:09:34 +03:00
maeneko de8a32ac41 Fix awg-ctrl hanging on the torrent flag; log out on an expired session
The torrent flag was read inside loops over users rows (users/stats,
peers, metrics) and each read queried the config table. SQLite has a
single connection here, so the nested query waited for it forever: the
first stats request or Prometheus scrape locked awg-ctrl for good, and
the panel showed no users and timed out.

- the block setting is kept in memory (loaded at start, updated on
  save), so the flag never touches the database
- the set of flagged IPs is taken once before the query, not per row
- regression test: collectMetrics must finish with users in the table

Panel: a 401 from the panel itself (its JWT not accepted) now returns to
the login screen; a 401 relayed from a node's awg-ctrl does not.
2026-10-18 00:08:17 +03:00
maeneko a84845cecb Bump to Beta 0.4.2
ci / check (push) Successful in 22m15s
release / release (push) Successful in 17m39s
2026-10-17 23:35:30 +03:00
maeneko c73da602d6 Block BitTorrent on the server
- awg-ctrl: optional nft table awg_torrent (GET/PUT /api/torrent, off by
  default). A connection is judged by its first packets, like Xray's
  sniffing: DHT, UDP trackers, uTP SYN, and with @ih support the plain
  TCP handshake and HTTP announce. A match marks the connection in
  conntrack and drops it whole; without @ih only the UDP part is used
- devices that tried are kept in a 10-minute set and flagged as
  "torrent" in user and device stats; with shaping on they sit in the
  background class while the flag lasts
- metrics: awg_torrent_block_enabled/active, awg_torrent_blocked_total,
  awg_torrent_devices and per-peer awg_peer_torrent
- panel: "Торренты" section in server settings, "торренты" chip on users
  and devices; Save sends only what changed
2026-10-17 23:35:30 +03:00
maeneko cf6af5ce0a Bump to Beta 0.4.1
ci / check (push) Successful in 31m14s
release / release (push) Successful in 18m43s
2026-10-17 21:36:51 +03:00
maeneko 105f184a93 Monitor: 7 and 30 day views, traffic used, merged tiles
- ranges 7d (30 min step) and 30d (2 h step); Prometheus keeps 31d
- rate() window is at least the step, so long views show averages
  instead of a 2-minute sample; "now" values still use a 2m window
- overview returns used_down/used_up: increase() of the interface
  counters over the whole range
- tiles merged: traffic (with volume for the period), devices, load
2026-10-17 21:36:51 +03:00
maeneko 2388a42275 release: allow a manual run that creates the tag
workflow_dispatch with a tag input; tag pushes work as before.
2026-10-14 14:14:48 +00:00
maeneko e70c5468f5 Bump to Beta 0.4.0, install from GitHub
- version 0.4.0 in install.sh, .env and the service package.json files
- the installer and its release archives come from GitHub by default;
  REPO_BASE / REPO_FALLBACK / ARCHIVE_URL still override the source
- README: install commands point to raw.githubusercontent.com
2026-10-14 13:28:03 +00:00
maeneko bac230f144 Ship and run the Go binary instead of Node
Releases now carry one archive per architecture with a static awg-control
binary (services and CLI), the built frontend and .env; the installer no
longer installs Node.js or runs npm.

- tools/release/package.sh: CGO-free builds for linux/amd64 and arm64,
  version injected into `awg-control version`; used by both the GitHub and
  Gitea release workflows
- install.sh: architecture check, per-arch archive, /usr/local/bin/awg-ctrl
  is a symlink to the binary, systemd unit without node in PATH, steps
  renumbered 1/6..6/6
- update from a Node install: the old services are stopped through the old
  CLI, then TS sources, node_modules and the old dist are removed; cli.env,
  panel.id and logs stay
- README: one binary, its roles, architecture requirement
2026-10-14 13:27:24 +00:00
maeneko 5aa0c3a2f5 Port the CLI to Go: awg-control <start|stop|…>
internal/cli reimplements cli/src: start/stop/restart/status with the
awg-ctrl health gate, credentials, join/unjoin, peers [drop], monitor
install/status/remove and the interactive menu. Same cli.env, pid files,
logs and messages. Without arguments or with an operator command the binary
acts as the CLI; ctrl/ui/agent stay service roles.

- services are started as the same binary (`awg-control ctrl|ui|agent`),
  detached into their own session, from the same working directories
- processes started by the Node CLI (tsx … index.ts / server.ts) are still
  recognised, so `stop` and `status` work across the switch
- after join the fresh restart gets the original environment, so a rejoin
  picks up the new cli.env instead of the inherited old CORE_* values
- install root: AWG_ROOT, else the nearest parent of the binary with cli/ or
  .env, else /opt/awg-control

Checked end to end on stubbed awg: start/status/stop, credentials, join to
the panel's own hub (with and without --no-panel), peers listing and drop,
unjoin, menu.
2026-10-14 13:27:24 +00:00
maeneko 70bb587216 Port awg-ui to Go: awg-control ui
internal/ui reimplements the awg-ui backend with the same HTTP surface; the
React frontend is unchanged and served from awg-ui/dist.

- web.go: Express-like router (express.json semantics, mounts, :params,
  HTML 400/404/413 pages, SPA fallback)
- server.go: JWT login with rate limit, API keys and their server sets,
  awg-ctrl client and proxy, /api/v1/users, delete hooks
- nodes.go: WSS hub for agents (pin, challenge-response, join strings),
  RPC with timeouts, health, /ui/nodes
- sub.go, audit.go, sub_admin.go: sen:// master keys and devices,
  per-server reconcile with tombstones and /api/peers/sync, signed /sub/v1
  listener, audit with two-pass confirmation and cleaning, panel and
  /api/v1/masterkeys routers
- metrics.go, monitor.go: /metrics exposition with tokens, /ui/monitor
- sen.go: link codec and protocol signatures, tested byte for byte against
  fixtures produced by awg-ui/sen.ts (tools/golden/sen.ts)

Differential bench (tools/diffbench/local-ui.sh, scenario-ui.ts,
compare-ui.mjs): 158 steps over every route group, including signed device
requests and a Go agent joining as a node, plus the resulting ui.db and
awg-ctrl state. TS and Go are identical after masking random values, across
repeated runs and with the race detector on.
2026-10-14 13:27:24 +00:00
maeneko c2e9636f31 awg-ctrl: keep CPU mode order in /api/metrics; diffbench compare and host runner
The differential bench (GEN 2 and 3.1, with I1, S4=0, upgrade with a 3.x
module) now shows the Go awg-ctrl identical to the TS one. The only real
difference it found: host.cpu was a Go map, so its keys came out sorted
instead of in /proc/stat order.

- tools/diffbench/compare.mjs: decodes vpn:// (Node and Go zlib differ), masks
  live host numbers, Express dev-mode stack traces, temp file names and the
  order of calls from concurrent background work
- tools/diffbench/local.sh: the same run without Docker, stubs only on the
  run's own PATH (for disposable machines)
- run.sh: wait for the background shaping apply before stopping the service,
  otherwise the stub log is cut short
2026-10-14 13:27:24 +00:00
maeneko 5687ba0d3d Drop awg-ui/package-lock.json added by mistake 2026-10-14 13:27:24 +00:00
maeneko 016d3de62d Port awg-agent to Go: awg-control agent
- internal/agent: outbound WSS to core with SPKI pin checked inside the TLS
  handshake, Ed25519 challenge-response, one-shot join secret (dropped from
  memory and cli.env after the first ready), forwarding of core requests to the
  local awg-ctrl with an internal token, dead-connection timer on pings,
  backoff up to a minute (a minute after 4003)
- path filter is stricter than the Node one where it relied on WHATWG URL
  normalisation: backslashes, a leading "//" and %2e dot segments are rejected
- tests: end-to-end against a fake TLS core and a fake awg-ctrl; also checked
  by hand against the real awg-ui/nodes.ts hub
- CI: Go build, vet and race tests on GitHub and Gitea
2026-10-14 13:27:24 +00:00
maeneko 59e076c1de Start the Go rewrite: jsjson, awg-ctrl port, differential bench
- internal/jsjson: JSON.stringify-compatible encoder and order-preserving decoder,
  tested against fixtures produced by Node itself
- internal/ctrl: awg-ctrl in Go (params, vpn:// keys, users, peers, sync, 3.1
  upgrade, CAKE shaping, metrics, monitor job, Express-compatible HTTP layer)
- internal/iauth, internal/logx: internal Ed25519 tokens and logging
- tools/diffbench: Docker bench with stubbed awg/ip/tc/nft that runs one scenario
  against the TS and Go implementations for comparison
2026-10-14 13:27:24 +00:00
maeneko 53d5a08bbd Add shaping, Prometheus, monitor tab and peer audit
Baseline TypeScript version before the Go rewrite: CAKE fair sharing with
automatic deprioritisation of heavy devices, per-key priority, /metrics with
a metrics token, the built-in Prometheus install/remove, the Monitor tab,
grouped sidebar tabs and the dead-peer audit.
2026-10-14 13:27:23 +00:00
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
160 changed files with 30268 additions and 1714 deletions
+2 -2
View File
@@ -7,8 +7,8 @@
#
# ⚠️ VERSION и CHANNEL продублированы в install.sh: установщик печатает баннер и
# собирает URL релиза ДО того, как архив с этим файлом скачан. При бампе версии
# правь оба места (и три package.json).
# правь оба места (и package.json сервисов).
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.3.1
VERSION=0.5.0
+12
View File
@@ -26,6 +26,18 @@ jobs:
- name: Installer syntax
run: bash -n install.sh
# Go-переписывание (awg-control): сборка, vet и тесты. composites выключен —
# jsjson.KV{k, v} без имён полей написан так намеренно.
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Go build, vet, test
run: |
go build ./...
go vet -composites=false ./...
go test -race ./...
- name: awg-ctrl types
working-directory: awg-ctrl
run: |
+14 -12
View File
@@ -1,5 +1,5 @@
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает тот
# же архив, но заливает его через API Gitea — gh CLI в раннере нет.
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает те
# же архивы, но заливает его через API Gitea — gh CLI в раннере нет.
#
# Gitea читает workflow из .gitea/workflows; когда этот каталог есть, файлы из
# .github/workflows она не запускает, так что GitHub-версия остаётся только для
@@ -39,12 +39,13 @@ jobs:
npm install
npm run build
- name: Package archive
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "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}/"
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Архив на архитектуру: Go-бинарник awg-control + собранный фронтенд + .env.
- name: Package archives
run: sh tools/release/package.sh "${GITHUB_REF_NAME#v}"
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
# зависит от версии движка выражений в Gitea.
@@ -58,7 +59,6 @@ jobs:
[ -n "$TOKEN" ] || { echo "нет токена: ни RELEASE_TOKEN, ни GITEA_TOKEN"; exit 1; }
VERSION="${GITHUB_REF_NAME#v}"
ARCHIVE="awgcontrol-${VERSION}.tar.gz"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
# id релиза — первое поле в JSON Gitea, поэтому берём ПЕРВОЕ вхождение
@@ -82,8 +82,10 @@ jobs:
fi
[ -n "$id" ] || { echo "не удалось получить id релиза"; exit 1; }
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
-H "Authorization: token $TOKEN" \
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
for ARCHIVE in awgcontrol-"${VERSION}"-linux-*.tar.gz; do
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
-H "Authorization: token $TOKEN" \
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
done
echo "опубликовано: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
+12
View File
@@ -23,6 +23,18 @@ jobs:
- name: Installer syntax
run: bash -n install.sh
# Go-переписывание (awg-control): сборка, vet и тесты. composites выключен —
# jsjson.KV{k, v} без имён полей написан так намеренно.
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Go build, vet, test
run: |
go build ./...
go vet -composites=false ./...
go test -race ./...
- name: awg-ctrl types
working-directory: awg-ctrl
run: |
+18 -9
View File
@@ -4,6 +4,12 @@ on:
push:
tags:
- 'v*'
# Ручной запуск: тег создаётся при публикации на коммите, с которого запущен workflow.
workflow_dispatch:
inputs:
tag:
description: 'Тег релиза, например v0.4.0'
required: true
permissions:
contents: write
@@ -11,6 +17,8 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
steps:
- uses: actions/checkout@v4
@@ -24,17 +32,18 @@ jobs:
npm install
npm run build
- name: Package archive
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "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}/"
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Архив на архитектуру: Go-бинарник awg-control + собранный фронтенд + .env.
- name: Package archives
run: sh tools/release/package.sh "${TAG#v}"
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
--title "${GITHUB_REF_NAME}" --generate-notes --prerelease
VERSION="${TAG#v}"
gh release create "${TAG}" awgcontrol-"${VERSION}"-linux-*.tar.gz \
--target "${GITHUB_SHA}" --title "${TAG}" --generate-notes --prerelease
+5 -1
View File
@@ -4,6 +4,7 @@
**/node_modules/
**/dist/
cli/cli.env
awg-ui/panel.id
logs/
npm-debug.log*
yarn-debug.log*
@@ -19,4 +20,7 @@ yarn-error.log*
# Все markdown, кроме README.md
*.md
!README.md
!README.md
# Go: бинарники и прогоны дифференциального стенда
tools/diffbench/bin/
tools/diffbench/out/
+383 -48
View File
@@ -1,25 +1,67 @@
# Forgetting
Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module)
Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module)
> [!WARNING]
> Данный проект предназначен только для использования в законных целях.
- **VPN-пользователи** с ключами `vpn://` для AmneziaVPN и **мастер-ключи** `sen://` для SenAWG:
одна ссылка на несколько устройств, каждое регистрируется само.
- **Несколько серверов** под одной панелью: ноды подключаются к ней сами, панель можно
вынести на отдельный сервер без VPN.
- **Журнал подключений**: кто, когда и сколько был в VPN, кто давно не подключался.
- **Мониторинг**: встроенный Prometheus и графики в панели, метрики для своего Prometheus.
- **Защита от обнаружения**: параметры обфускации, порты и пути у каждого сервера свои, а
открытые порты отвечают сканеру как пустой веб-сервер.
## Содержание
- [Архитектура](#архитектура)
- [Требования](#требования)
- [Установка](#установка) · [Параметры установщика](#параметры-установщика) · [Обновление и удаление](#обновление-и-удаление)
- [Несколько серверов](#несколько-серверов)
- [Защита от обнаружения](#защита-от-обнаружения)
- [Журнал подключений](#журнал-подключений)
- [Мониторинг](#мониторинг)
- [Панель за nginx](#панель-за-nginx)
- [API](#api)
- [Управление](#управление) · [Настройки `cli.env`](#настройки-clienv)
## Архитектура
Три компонента, устанавливаются в `/opt/awg-control/`:
Один статический бинарник `awg-control` (Go) в `/opt/awg-control/` — в нём все роли;
команда `awg-ctrl` — ссылка на него:
| Компонент | Роль |
|---|---|
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), прокси к awg-ctrl, публичный API `/api/v1` и своя база его ключей. Единственный сервис, доступный по сети. |
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу (`users.db`) и журнал подключений, генерирует `vpn://` ключи. Слушает только loopback. |
| **awg-ui** (`awg-control ui`) | Веб-панель (React, `awg-ui/dist`), вход по JWT, прокси к awg-ctrl, публичный API `/api/v1`, подписка `sen://`, хаб нод и `/metrics`. База — `ui.db`. |
| **awg-agent** (`awg-control agent`) | Только на ноде: исходящее WSS-соединение к панели, исполняет её запросы на локальном awg-ctrl. |
| **cli** (`awg-ctrl …`) | Менеджер процессов, логин и пароль панели, привязка нод, перенос панели, мониторинг, TUI-меню. |
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только
у awg-ui, awg-ctrl проверяет подпись публичным. Данные — в `/etc/amnezia/amneziawg/` (вне
каталога установки, переустановку переживают), настройки сервисов — в
`/opt/awg-control/cli/cli.env`.
Порты наружу (номера у каждого сервера случайные):
| Порт | Что | Кто открывает |
|---|---|---|
| `SERVER_PORT`/udp | VPN-клиенты | везде, где есть VPN |
| `UI_PORT`/tcp | панель и API, под секретным путём | панель |
| `SUB_PORT`/tcp | подписка `sen://` (по умолчанию HTTPS) | панель |
| `NODE_PORT`/tcp | хаб нод (WSS) | панель, если принимает ноды |
Нода открывает только UDP для клиентов: к панели она подключается сама.
## Требования
- **ОС**: Ubuntu 24.04+ (Тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и переопределяется через `AMNEZIA_PPA_SUITE`)
- **ОС**: Ubuntu 24.04+ (тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка
экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и
переопределяется через `AMNEZIA_PPA_SUITE`).
- **Права**: root.
- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG).
- **Виртуализация**: KVM или железо (не OpenVZ/LXC/Docker/WSL — нужен загружаемый
kernel-модуль AmneziaWG).
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
- **Node.js 20.x** (ставится установщиком автоматически).
- **Архитектура**: x86_64 или aarch64.
Совместимость установщик проверяет сам перед любыми изменениями.
@@ -28,40 +70,228 @@
На сервере, от root:
```bash
bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
```
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
на 2.0 можно так:
Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с
`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор с текстом скрипта.
```bash
AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)"
```
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
автоматически.
Установщик спросит роль сервера:
Источник архива и имя панели не зашиты в код — задаются переменными
`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика
(подробности — в комментариях `install.sh` и в `.env`).
| Роль | Что ставится |
|---|---|
| **Панель + VPN** (`standalone`) | Всё на одной машине; к ней можно подключать ноды |
| **Только панель** (`core`) | Панель без VPN; серверы подключаются к ней нодами |
| **Нода** (`node`) | VPN без панели; подключается к панели по строке `awgjoin://…` («Добавить сервер» в панели) |
В конце установщик печатает адрес панели **вместе с секретным путём** —
`http://IP:PORT/<путь>/` — логин и пароль. Без пути панель отвечает `404`, поэтому адрес
сохраните целиком; позже его покажут `awg-ctrl status` и `awg-ctrl credentials`.
Порты на самом сервере установщик открывает сам (UFW, firewalld или iptables — что
активно). **Фаервол хостера** (панель провайдера, security group) отсюда не виден: открой там
порты из итога установки, иначе клиенты не подключатся, а на сервере всё будет выглядеть
исправным.
Сервер ставится на AmneziaWG 3.1. Если модуль в ядре старше 3.x или ядро старше 5.5,
установка остановится с диагностикой — поставить на 2.0 можно через `AWG_GEN=auto`. Перевести
сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели: пользователи сохраняются,
ключи перевыпускаются автоматически.
### Параметры установщика
Задаются переменными окружения перед запуском, например
`ROLE=node JOIN='awgjoin://…' bash <(curl -Ls …/install.sh)`. Всё необязательно.
| Переменная | Что делает |
|---|---|
| `ROLE` | `standalone`, `core` или `node` — без вопроса |
| `JOIN` | строка `awgjoin://…` для ноды |
| `AWG_GEN` | `3.1` (по умолчанию), `auto` — 3.1, если ядро позволяет, иначе 2.0; `2.0` — не трогать модуль |
| `AWG_PIN` | `y` (по умолчанию) — закрепить версию пакетов AmneziaWG (`apt-mark hold`) |
| `AWG_PORT` | UDP-порт VPN вместо случайного |
| `AWG_MTU` | MTU туннеля вместо случайного (1280–1500) |
| `UI_PORT`, `UI_USER`, `UI_PASS` | порт, логин и пароль панели — без вопросов |
| `UI_PREFIX` | свой секретный путь панели (8–64 символа `A–Z a–z 0–9 _ -`); `off` — без пути |
| `SERVER_NAME` | имя VPN-сервера — без вопроса |
| `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов |
| `ACTION` | `update`, `reinstall` или `uninstall` — действие без меню |
| `UNINSTALL` | `y` — удалить без меню и без подтверждения |
| `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) |
| `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `AWG_LOCAL_TARBALL` | откуда брать архив релиза (своё зеркало, локальный файл) |
| `BRAND`, `CHANNEL` | имя продукта в баннерах и панели |
### Обновление и удаление
Повторный запуск установщика на сервере с установкой — или пункты **7–9** меню `awg-ctrl`, или
команды `awg-ctrl update | reinstall | uninstall` — предлагает:
1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. Установке без
секретного пути панели он выдаётся, и установщик печатает новый адрес: закладки и адрес
внешнего API нужно обновить. Оставить без пути — `UI_PREFIX=off` перед запуском или пустое
`UI_PREFIX=` в `cli.env`.
2. **Переустановить** — с нуля; пользователей и ключ сервера можно сохранить. Тогда по умолчанию
остаются и порты (VPN, панели, подписки, хаба), параметры обфускации, MTU, секретный путь,
логин и пароль панели — они зашиты в выданные ключи, ссылки и закладки. Прежняя установка
убирается только после ответа «да» на «Продолжить?».
3. **Удалить** — полностью (без меню: `UNINSTALL=y`, подтверждение «удалить» тогда не
спрашивается).
Удаление снимает сервисы и юнит systemd, интерфейс `awg1`, nft-таблицы шейпинга и блокировки
торрентов, открытые установщиком порты, Prometheus из `awg-ctrl monitor install`,
`/opt/awg-control`, `/etc/amnezia` (пользователи, ключи, базы), команду `awg-ctrl`, sysctl-файл
и логи установки (в них пароль панели). Перед этим установщик предлагает сохранить копию данных
в `/root/forgetting-backup-<время>.tar.gz`. AmneziaWG (пакеты, модуль, PPA) — по отдельному
вопросу; общие зависимости (dkms, build-essential, iptables, заголовки ядра) остаются. Ноду
после удаления уберите и из списка серверов панели.
## Несколько серверов
**Подключить сервер.** В панели «Добавить сервер» выдаёт строку `awgjoin://…` (действует час).
На новом сервере — установщик с ролью «Нода» и этой строкой, или на уже установленном —
`awg-ctrl join '<awgjoin://…>'`. Агент ноды сам подключается к хабу панели по WSS; доверие —
закреплённый отпечаток сертификата хаба и ключ ноды. В панели нода появляется в списке
серверов, ею управляют так же, как своим. `awg-ctrl unjoin` — отвязать.
**Перенести панель на отдельный сервер** (например, без VPN). «Настройки» → «Перенос» или CLI:
1. На новом сервере — установка с ролью «Только панель».
2. На новой панели — код переноса `awgmove://…`; на старой — вставить его (или
`awg-ctrl migrate export` на старом и `awg-ctrl migrate import <архив>` на новом).
3. Старый сервер становится нодой #0 новой панели и пересылает туда свои прежние порты:
ссылки `sen://`, ноды и внешний API продолжают работать, закладки панели — тоже (новая
панель принимает и свой секретный путь, и старый).
4. Когда раздел «Перенос» покажет, что новый адрес знают все устройства и ноды, старый
сервер можно выключать. Ноды, переехавшие вручную, — `awg-ctrl rehome <хост[:порт]>`.
`awg-ctrl migrate cancel` — отменить на старом сервере, пока перенос не завершён.
## Защита от обнаружения
Раньше у всех установок были одинаковые порт, параметры обфускации и ответы на запросы — одно
правило DPI находило их все. Теперь общего у серверов нет:
**VPN.** На новой установке случайны:
- UDP-порт (20000–59999), если не задан `AWG_PORT`;
- параметры обфускации `Jc/Jmin/Jmax`, `S1–S4` (размеры handshake-пакетов разные — по длине
тип пакета не узнать), `H1–H4` (непересекающиеся диапазоны);
- `I1` — пакет-маскировка перед handshake: DNS-запрос к случайному популярному домену (формат
проверяется до записи — его принимают и kernel-модуль, и amneziawg-go);
- MTU в диапазоне 1320–1376.
При переустановке с сохранением данных всё это берётся из прежнего `awg1.conf`: параметры
зашиты в выданные ключи. Поэтому **серверы, поставленные до рандомизации, сохраняют старые общие
значения** — чтобы уйти от них, нужна переустановка без сохранения данных и раздача новых
ключей. Исключение — `I1`: его сервер не сверяет, и если в конфиге его не было, при переустановке
он генерируется новым, не ломая старые ключи.
**Открытые порты** отвечают тому, кто не доказал право на ответ, как пустой веб-сервер на Go —
`404 page not found`:
| Порт | Кому отвечает по протоколу |
|---|---|
| Панель | запросам под секретным путём (`UI_PREFIX`); с loopback — и без него |
| Подписка `sen://` | `peek`/`register` с секретом мастер-ключа из ссылки; остальным запросам — с подписью ключом устройства |
| Хаб нод | агенту, подписавшему своим ключом сам запрос на WebSocket |
Подробности:
- `GET /ui/brand` (имя и версия) — только после входа; экран логина не показывает имя продукта.
- Удалённому устройству подписка отвечает `410` (снять конфиг), только если запрос подписан его
ключом (`revoked_devices`); устройства, удалённые до этой версии, получают тихий `404`.
- Пока выдан код переноса, `POST /migrate/v1/import` принимается и без секретного пути.
- Агенты нод прежних версий подписи не шлют: хаб пускает их, пока в панели есть нода, ни разу
не подключавшаяся по-новому, и пишет об этом в лог. Обновили все ноды — путь без подписи
закрывается сам (`HUB_LEGACY` в `cli.env` — принудительно). Часы ноды могут расходиться с
панелью до суток.
- Самоподписанные сертификаты подписки и хаба получают случайное имя вместо общих `sen` /
`awg-node`. Клиенты и ноды сверяют ключ, а не имя; прежние сертификаты остаются со старым именем.
**Чего это не закрывает.** Секретный путь и тихий `404` защищают от активного сканирования.
Панель по умолчанию работает по голому HTTP — её трафик (пути, JSON, токены) виден в сети.
Ставьте её за nginx с TLS ([ниже](#панель-за-nginx)) или заходите по VPN. Подписка `sen://`
по умолчанию HTTPS, хаб — всегда TLS.
## Журнал подключений
Вкладка «Подключения» показывает по выбранному серверу:
- **События** — кто подключился к VPN и отключился, когда, сколько длилась сессия и сколько
скачал и отдал. Поиск — по имени пользователя, мастер-ключу, устройству или внутреннему IP.
- **Кто когда был** — все пользователи и устройства `sen://` сервера: онлайн сейчас, когда
были на связи последний раз и кто не подключался ни разу с начала журнала.
Журнал ведёт awg-ctrl каждого сервера (`conn_log` в `users.db`, 90 дней). У WireGuard нет событий
подключения, поэтому раз в 30 с он сверяет время handshake и счётчики трафика: «подключился» —
время первого handshake, «отключился» — последняя активность (точность около 30 с). Перезапуск
awg-ctrl сессии не рвёт, падение интерфейса отключением не считается. Внешний IP клиентов не
записывается. Серверы на прежней версии журнала не ведут — вкладка попросит их обновить.
## Мониторинг
**Встроенный Prometheus.** «Настройки» → «Prometheus» → «Установить»: панель просит awg-ctrl
поставить его (`awg-ctrl monitor install`, только Debian/Ubuntu). Он слушает только
`127.0.0.1:9090`, хранит 31 день и забирает метрики **всех серверов** панели. Графики — скорость,
устройства онлайн, нагрузка, CPU и память — появляются через минуту-две. Удалить — там же или
`awg-ctrl monitor remove`. Графики — во вкладке «Монитор».
У панели без своего VPN Prometheus ставится на ноду (выберите её в шапке панели). Он
забирает метрики у агента на `127.0.0.1`, а агент — у панели внутри TLS-соединения с хабом;
панель читает графики через того же агента. Снаружи этот трафик не отличить от обычной связи
ноды с панелью, секретный путь ему не нужен. Агенты старше 0.4.6 этого не умеют.
**Свой Prometheus.** «Настройки» → «Prometheus» → «Токен метрик»: выдать токен «только метрики»
(он не открывает ни панель, ни API) и скопировать готовый фрагмент `prometheus.yml`. Адрес в нём
берётся от страницы панели, поэтому уже содержит секретный путь
(`metrics_path: /<путь>/metrics`) или подпапку nginx. По голому HTTP токен и метрики идут
открытым текстом — скрейпьте через nginx с TLS или по VPN. Метрики по отдельным пирам
выключены (`METRICS_PEERS=on` в `cli.env` — включить).
## Панель за nginx
Панель работает и в корне домена, и в любой подпапке — пути в ней относительные. Пример для
`https://example.com/forgetting/` (`39708` — `UI_PORT` из `/opt/awg-control/cli/cli.env`):
```nginx
location = /forgetting { return 301 /forgetting/; }
location ^~ /forgetting/ {
proxy_pass http://127.0.0.1:39708/; # слэш в конце срезает префикс
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
nginx на этой же машине ходит к панели по loopback, поэтому секретный путь ему не нужен —
подпапка в nginx и становится путём панели (выберите неочевидную). Адрес клиента панель берёт
из `X-Forwarded-For` / `X-Real-IP` только от доверенного прокси: с loopback — всегда, с других
адресов — если они перечислены в `TRUST_PROXY`. Без этого все входы шли бы с адреса прокси и
делили один лимит неудачных попыток.
Сам порт панели после этого можно закрыть снаружи (`ufw deny <UI_PORT>/tcp`). Порты подписки
(`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты и ноды ходят на них
напрямую, не через nginx.
## API
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
стабильный контракт для сторонних программ, авторизация по API-ключу.
**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый
URL в обоих случаях — адрес сервера и порт, выбранный при установке.
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — стабильный контракт
для сторонних программ, авторизация по API-ключу. **Роуты панели** — всё, что умеет
веб-интерфейс, авторизация по JWT.
В примерах `HOST:PORT` — адрес панели **вместе с секретным путём**:
`http://203.0.113.9:39708/k3f9xQ2mTq8vLw0a`. Без пути снаружи любой запрос получает `404`.
> [!WARNING]
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не
> публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет
> без TLS и ограничения по адресам.
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не публичный контракт,
> в отличие от `/api/v1`. По голому HTTP они идут открытым текстом: ставьте панель за TLS.
### Публичный API (`/api/v1`)
Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`.
Ключ создаётся в «Настройках», на вкладке «API-ключи», и уходит в заголовке `X-Api-Key`.
Открытое значение показывается один раз при создании — на сервере лежит только
SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля:
`psk_key` и `pub_key` не покидают сервер.
@@ -79,9 +309,40 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
| `GET /api/v1/users/:name` | `{ name, ip, gen, vpn_key }`; `404` — не найден |
| `DELETE /api/v1/users/:name` | `{ "success": true, "name": "alice" }` |
`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или
`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить»
ключ = удалить и создать заново.
`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или `"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` серверов ждут ноду |
| Код | Когда |
|---|---|
@@ -89,11 +350,11 @@ curl -s -X POST http://HOST:PORT/api/v1/users \
| `401 {"error":"Invalid API key"}` | ключ не найден — удалён или неверен |
| `502 {"error":"awg-ctrl недоступен"}` | awg-ui посредничает, а awg-ctrl не отвечает |
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне
каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про
внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией.
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` и
`DELETE /ui/apikeys/:id`.
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`), поэтому переустановка их
не трогает. Сама awg-ctrl про внешние ключи ничего не знает: awg-ui ходит к ней своей
внутренней авторизацией. Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys`
(с полем `servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не
перевыпуская ключ, и `DELETE /ui/apikeys/:id`.
### Авторизация панели
@@ -108,19 +369,20 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые
дёргает сама панель; в отличие от `/api/v1` их форма может меняться.
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`; сервер — заголовком `X-Server-Id`
(без него — сервер по умолчанию). Ниже — роуты, которые дёргает сама панель; в отличие от
`/api/v1` их форма может меняться.
### Состояние
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.5.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс:
`"2"` или `"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс: `"2"` или
`"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
### Пользователи
@@ -140,8 +402,16 @@ curl -s -X POST http://HOST:PORT/api/users \
```
Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`,
base64url-декодировать, отбросить первые 4 байта (длина), распаковать
`zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
base64url-декодировать, отбросить первые 4 байта (длина), распаковать `zlib inflate` → JSON;
текст конфига лежит в `last_config.config`.
### Журнал подключений
| Метод | Ответ |
|---|---|
| `GET /ui/connlog` | Журнал сервера из `X-Server-Id`: `{ events: [{ id, at, event, name, owner, ip, key?, device?, duration?, rx?, tx? }], peers: [{ name, owner, ip, online, last_seen, key?, device? }], since, keep_days }`. `event` — `connect` или `disconnect` (у него `duration`, `rx`, `tx` — со стороны сервера); `key`/`device` — мастер-ключ и устройство `sen://`. Параметры: `limit` (до 500), `before=<id>` — следующая страница, `q` — поиск по имени, владельцу, IP. `501` — сервер старой версии |
Без имён `sen://` тот же журнал отдаёт awg-ctrl: `GET /api/connlog`.
### Интерфейс AWG
@@ -151,22 +421,87 @@ base64url-декодировать, отбросить первые 4 байта
| `POST /awg/restart` | `awg-quick down/up` + ресинк пиров, `{ "success": true }`. Соединения клиентов кратковременно рвутся |
| `POST /awg/upgrade` | Переводит сервер с 2.0 на 3.1: правит `awg1.conf`, перезапускает интерфейс и перевыпускает все ключи. `{ gen, backup, bumpedS: [], reissue: { total, reissued, … } }` |
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не
3.x или ядро старше 5.5, и `500` с текстом причины, если ядро не приняло
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
поднимается обратно на 2.0.
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не 3.x или ядро
старше 5.5, и `500` с текстом причины, если ядро не приняло 3.1-конфиг — в этом случае прежний
конфиг возвращается из копии, а интерфейс поднимается обратно на 2.0.
### Мастер-ключи
Те же операции, что в `/api/v1/masterkeys`, но без ограничения набором серверов.
| Метод | Что делает |
|---|---|
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }], defaults }`; `:id` в путях — id или `uuid`; `defaults` — серверы новых ключей или `null` |
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201`; без `servers` — `defaults`, а нет их — сервер по умолчанию |
| `POST /ui/masterkeys/servers` | «Серверы для всех»: `{ add?, remove?, defaults?, dry_run? }` — `add` появляются у всех живых ключей, `remove` снимаются у всех, прочие серверы ключей не трогаются; `defaults` (`null` — сбросить) — набор новых ключей. → `{ changed, total, hidden: [{ id, label, keys }] }`, `hidden` — ключи API, которые перестанут видеть столько мастер-ключей (сервер вне их области). `409`, если у ключа не останется серверов |
| `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 у устройства |
### Метрики
| Метод | Что делает |
|---|---|
| `GET /ui/metrics-token` | `{ exists, prefix?, created_at?, last_used?, per_peer }` — токен для своего Prometheus |
| `POST /ui/metrics-token` | Выпустить или перевыпустить: `201` с `token` (показывается один раз); старый перестаёт работать |
| `DELETE /ui/metrics-token` | Отозвать |
| `GET /metrics` | Экспозиция Prometheus по всем серверам; `Authorization: Bearer awgm_…` |
## Управление
```bash
awg-ctrl
awg-ctrl # меню
awg-ctrl start|stop|restart|status # status показывает и адрес панели с путём
awg-ctrl credentials [user|pass] # логин и пароль панели, адрес
awg-ctrl join '<awgjoin://…>' [--no-panel] / unjoin
awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес
awg-ctrl peers [drop <область>] # пиры sen:// на этом сервере по панелям
awg-ctrl monitor install|status|remove # встроенный Prometheus
awg-ctrl migrate export [файл] # перенос панели (см. «Несколько серверов»)
awg-ctrl migrate import <архив>
awg-ctrl migrate cancel
awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh)
awg-ctrl panel [path new|off|<путь>] # адрес панели; новый, свой или без секретного пути
awg-ctrl log [<поиск>] [-n N] # журнал VPN-подключений этого сервера
awg-ctrl log seen # кто когда был: онлайн, последний раз, не подключался
awg-ctrl metrics [token|revoke] # токен и prometheus.yml для своего Prometheus
awg-ctrl hub [legacy on|off|auto] # ноды хаба: кто с подписью, кто ещё старый
awg-ctrl check # что на сервере выдаёт его сканеру и DPI
```
В меню `awg-ctrl` те же возможности — пункты 10–13: адрес и секретный путь панели, журнал,
«кто когда был» и проверка скрытности.
`update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё
зеркало), `uninstall` берёт его копию из установки и работает без сети.
Через systemd (автозапуск на загрузке):
```bash
systemctl start|stop|restart awg-control
```
### Настройки `cli.env`
`/opt/awg-control/cli/cli.env` пишет установщик; после правки — `awg-ctrl restart`. Чаще всего
нужны:
| Переменная | Что делает |
|---|---|
| `UI_PORT`, `UI_USER`, `UI_PASS` | порт, логин и пароль панели (`awg-ctrl credentials`) |
| `UI_PREFIX` | секретный путь панели; пусто — без пути |
| `UI_PREFIX_MOVED` | путь панели, переехавшей сюда: принимается вместе со своим |
| `SUB_PORT`, `SUB_TLS`, `SUB_HOST` | подписка `sen://`: порт, HTTPS, адрес в ссылках |
| `NODE_PORT` | хаб нод; пусто — ноды не принимаются |
| `HUB_LEGACY` | `on` / `off` — пускать ли агентов нод без подписи; пусто — пока не обновлены все |
| `TRUST_PROXY` | IP или сети reverse-proxy, кроме loopback: от них верим `X-Forwarded-For` |
| `METRICS_PEERS` | `on` — метрики по отдельным пирам |
| `AUDIT` | `off` — выключить периодический аудит пиров |
| `INSTALLER_URL` | откуда `awg-ctrl update`/`reinstall` берут установщик (своё зеркало) |
## Лицензия
[MIT](LICENSE) © 2026 Ivan Vasilev
+11
View File
@@ -23,6 +23,16 @@ const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnez
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
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);
@@ -138,6 +148,7 @@ function connectOnce(): Promise<{ authFailed: boolean }> {
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
};
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
if (VERSION) auth.version = VERSION;
ws.send(JSON.stringify(auth));
} else if (m.t === "ready") {
ready = true; backoff = 1000; armDead();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-agent",
"version": "0.3.1",
"version": "0.5.0",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
+282 -6
View File
@@ -1,15 +1,16 @@
// 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 fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, closeSync } from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import { execSync, spawnSync } from "child_process";
import { execSync, spawnSync, execFile, spawn } from "child_process";
import * as zlib from "zlib";
import express, { Request, Response, NextFunction } from "express";
import Database from "better-sqlite3";
import winston from "winston";
import { createShaping } from "./shaping";
const logsDir = path.join(process.cwd(), "logs");
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
@@ -305,6 +306,7 @@ const stmts = {
peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"),
peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"),
peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"),
peerOwner: db.prepare<[string, string]>("UPDATE peers SET owner = ? WHERE pub_key = ?"),
userPubTaken: db.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?"),
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
all: db.prepare<[], UserRow>("SELECT * FROM users"),
@@ -574,6 +576,26 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
} catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; }
}
// Шейпинг (CAKE) и сэмплер тяжёлых устройств. Счётчики читаем асинхронно: цикл
// не должен блокировать event loop, как это делают остальные вызовы awg.
const shaping = createShaping({
iface: CONFIG.interface,
getCfg, setCfg, logger,
// CAKE видит только внутренний IP-пакет, на проводе к нему добавляются
// IPv4 (20) + UDP (8) + заголовок WG (32) = 60 байт и случайный префикс S4.
// Формулу сверять iperf3-замером на стенде (SHAPE_OVERHEAD перекрывает её).
overhead: () => 60 + (Number(CONFIG.awgParams.S4) || 0),
peers: () => new Promise((resolve, reject) => {
execFile("awg", ["show", CONFIG.interface, "dump"], { encoding: "utf8", timeout: 10_000 }, (err, out) => {
if (err) { reject(err); return; }
const rows = out.split("\n").slice(1).map(l => l.split("\t"))
.filter(f => f[0] && f[3])
.map(f => ({ ip: f[3].split(",")[0].split("/")[0], rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 }));
resolve(rows);
});
}),
});
function rebuildConf() {
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
const devices = stmts.peersAll.all();
@@ -679,6 +701,7 @@ function restartAwg() {
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
const gen = reloadAwgParams();
syncPeers();
void shaping.apply(); // tc-правила жили на интерфейсе, который мы только что пересоздали
const versions = readAwgVersions();
CONFIG.awgModule = versions.module;
CONFIG.awgTools = versions.tools;
@@ -802,6 +825,7 @@ function startInterface() {
const r = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`);
syncPeers();
void shaping.apply();
return getInterfaceStatus();
}
@@ -848,12 +872,30 @@ function addUser(username: string): UserRow {
};
}
// Снять пира с интерфейса и убедиться, что его там нет. Раньше результат `awg set … remove`
// не проверялся: строку из базы удаляли, а пир в ядре оставался живым, и про него уже никто не
// знал. Теперь порядок такой: сначала ядро, потом база — при отказе строка остаётся, и
// повторная попытка (надгробие панели, следующий sync) найдёт пира.
function kernelPeers(): Set<string> | null {
const r = spawnSync("awg", ["show", CONFIG.interface, "peers"]);
return r.status === 0 ? new Set(r.stdout.toString().split("\n").map(l => l.trim()).filter(Boolean)) : null;
}
function dropFromKernel(pub: string): boolean {
const r = spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
if (r.status === 0) return true;
const left = kernelPeers();
if (left && !left.has(pub)) return true; // ответ странный, но пира уже нет
logger.error("peer remove from kernel failed", { pub, stderr: r.stderr?.toString() });
return false;
}
function removeUser(username: string) {
const user = stmts.get.get(username);
if (!user) throw new Error("Пользователь не найден");
if (!dropFromKernel(user.pub_key)) throw new Error("Не удалось снять пира с интерфейса");
stmts.delete.run(username);
spawnSync("awg", ["set", CONFIG.interface, "peer", user.pub_key, "remove"]);
rebuildConf();
for (const ext of [".conf", ".key"]) {
@@ -919,7 +961,9 @@ function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: str
stmts.peerRekey.run(oldPub, newPub);
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
}
spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]);
// Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа не откатываем:
// новый уже стоит, а клиент к этому моменту получил бы ошибку.
if (!dropFromKernel(oldPub)) logger.error("peer rekey: старый ключ остался в ядре", { owner: old.owner });
rebuildConf();
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
return { ip: old.ip, psk_key: old.psk_key };
@@ -940,14 +984,57 @@ function rotatePeerPsk(pub: string): { ip: string; psk_key: string } {
function removePeer(pub: string) {
const peer = stmts.peerGet.get(pub);
if (!peer) throw new HttpError(404, "Пир не найден");
if (!dropFromKernel(pub)) throw new Error("Не удалось снять пира с интерфейса");
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;
for (const p of stmts.peersAll.all()) {
if (p.owner.startsWith(prefix)) {
// Ядро первым: не снялся — строка остаётся, следующий sync попробует снова.
if (!want.has(p.pub_key) && dropFromKernel(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++;
}
}
if (removed.length || claimed) rebuildConf();
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
const all = stmts.peersAll.all();
return {
present: all.filter(p => p.owner.startsWith(prefix)).map(p => p.pub_key),
removed: removed.length,
legacy: all.filter(p => LEGACY_OWNER.test(p.owner)).map(p => ({ pub_key: p.pub_key, owner: p.owner, ip: p.ip })),
};
}
const app = express();
app.use(express.json({ limit: "1kb" }));
// Сведение пиров несёт полный список ключей панели — ему лимит побольше, остальным хватает 1kb.
const SYNC_PATH = "/api/peers/sync";
const jsonSmall = express.json({ limit: "1kb" });
const jsonSync = express.json({ limit: "512kb" });
// /api/audit/clean тоже несёт до SYNC_MAX ключей — под 1kb он принимал бы около двадцати.
const BIG_BODY_PATHS = new Set([SYNC_PATH, "/api/audit/clean"]);
app.use((req, res, next) => (BIG_BODY_PATHS.has(req.path) ? jsonSync : jsonSmall)(req, res, next));
function verifyInternalToken(token: string): boolean {
try {
@@ -1030,6 +1117,8 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
rx: peers[u.pub_key]?.rx ?? 0,
tx: peers[u.pub_key]?.tx ?? 0,
// «normal» | «background»: фоновый — пока канал занят, а устройство давно качает больше своей доли
priority: shaping.priorityOf(u.ip),
})),
});
}));
@@ -1066,10 +1155,51 @@ app.get("/api/peers", auth, handler((_req, res) => {
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
rx: stats[p.pub_key]?.rx ?? 0,
tx: stats[p.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(p.ip),
})),
});
}));
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять их
// после сбоя `awg set … remove` некому. Только чтение — панель (core) сама решает, что делать.
app.get("/api/audit", auth, handler((_req, res) => {
const r = spawnSync("awg", ["show", CONFIG.interface, "dump"]);
if (r.status !== 0) throw new HttpError(503, "awg show не ответил");
const known = new Set<string>([
...(db.prepare("SELECT pub_key FROM users").all() as { pub_key: string }[]).map(u => u.pub_key),
...stmts.peersAll.all().map(p => p.pub_key),
]);
const now = Math.floor(Date.now() / 1000);
const ghosts = r.stdout.toString().split("\n").slice(1).map(l => l.split("\t")).filter(f => f[0] && !known.has(f[0])).map(f => {
const lastHandshake = Number(f[4]) || 0;
return { pub_key: f[0], lastHandshake, online: lastHandshake > 0 && now - lastHandshake < 180, rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 };
});
res.json({ ghosts });
}));
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется: пир, который
// успел появиться в users/peers, не трогаем. Строк в базе у «призраков» нет — снимается только ядро.
app.post("/api/audit/clean", auth, handler((req, res) => {
const { pub_keys } = (req.body ?? {}) as { pub_keys?: unknown };
if (!Array.isArray(pub_keys) || pub_keys.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
const removed: string[] = [], skipped: string[] = [];
for (const k of pub_keys.map(checkPubKey)) {
const known = stmts.peerGet.get(k) || (db.prepare("SELECT 1 FROM users WHERE pub_key = ?").get(k));
if (known || k === getServerPublicKey() || !kernelPeers()?.has(k)) { skipped.push(k); continue; }
(dropFromKernel(k) ? removed : skipped).push(k);
}
if (removed.length) logger.warn("ghost peers removed from kernel", { count: removed.length, removed });
res.json({ removed, skipped });
}));
// Должен стоять ДО 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)));
@@ -1122,6 +1252,148 @@ app.post("/awg/upgrade", auth, handler((_req, res) => {
}
}));
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не пропустит
// (и без PATCH: агент отвечает на него 400).
app.get("/api/shaping", auth, handler(async (_req, res) => {
res.json(await shaping.status());
}));
app.put("/api/shaping", auth, handler(async (req, res) => {
const b = (req.body ?? {}) as Record<string, unknown>;
const mbit = (v: unknown, what: string): number => {
const n = Number(v);
if (!Number.isInteger(n) || n < 0 || n > 100_000) throw new HttpError(400, `${what}: целое число Мбит/с от 1 до 100000`);
return n;
};
if (typeof b.enabled !== "boolean" || typeof b.auto !== "boolean")
throw new HttpError(400, "enabled и auto должны быть true/false");
const downMbit = mbit(b.downMbit ?? 0, "Загрузка");
const upMbit = mbit(b.upMbit ?? 0, "Отдача");
if (b.enabled && (downMbit < 1 || upMbit < 1))
throw new HttpError(400, "Для включения укажите ширину канала в обе стороны");
try {
await shaping.update({ enabled: b.enabled, auto: b.auto, downMbit, upMbit });
} catch (e) {
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
throw new HttpError(500, e instanceof Error ? e.message : "Не удалось применить шейпинг");
}
res.json(await shaping.status());
}));
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с —
// несколько скрейперов не должны гонять `awg show` чаще.
const METRICS_TTL_MS = 5_000;
let metricsCache: { at: number; data: Promise<unknown> } | null = null;
function linkCounter(name: string): number {
try { return Number(readFileSync(`/sys/class/net/${CONFIG.interface}/statistics/${name}`, "utf8")) || 0; }
catch { return 0; }
}
// Нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у node_exporter): долю считает
// Prometheus через rate(), здесь мгновенного «процента» нет. Память — MemAvailable, а не MemFree: кэш ядра свободен.
const CPU_MODES = ["user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"] as const;
function readHost() {
try {
const first = readFileSync("/proc/stat", "utf8").split("\n")[0].trim().split(/\s+/).slice(1).map(Number);
const cpu: Record<string, number> = {};
CPU_MODES.forEach((m, i) => { cpu[m] = (first[i] || 0) / 100; }); // USER_HZ = 100 на всех обычных ядрах
const mem = readFileSync("/proc/meminfo", "utf8");
const kb = (k: string) => Number(mem.match(new RegExp(`^${k}:\\s+(\\d+) kB`, "m"))?.[1] ?? 0) * 1024;
return {
cpu, cores: os.cpus().length, load: os.loadavg(),
mem: { total: kb("MemTotal"), available: kb("MemAvailable") },
};
} catch { return null; }
}
async function collectMetrics() {
const st = getInterfaceStatus();
const stats = st.up ? getPeersData() : {};
const list: { kind: "user" | "device"; name: string; ip: string; online: boolean; rx: number; tx: number; priority: string }[] = [
...(db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[]).map(u => ({
kind: "user" as const, name: u.name, ip: u.ip,
online: stats[u.pub_key]?.online ?? false, rx: stats[u.pub_key]?.rx ?? 0, tx: stats[u.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(u.ip),
})),
...stmts.peersAll.all().map(p => ({
kind: "device" as const, name: "", ip: p.ip,
online: stats[p.pub_key]?.online ?? false, rx: stats[p.pub_key]?.rx ?? 0, tx: stats[p.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(p.ip),
})),
];
const all = Object.values(stats);
return {
up: st.up,
gen: CONFIG.gen,
peers: { total: st.peers, online: all.filter(p => p.online).length },
// С точки зрения сервера: rx — принято от клиентов (их отдача), tx — отправлено клиентам (их загрузка).
rx_bytes: all.reduce((a, p) => a + p.rx, 0),
tx_bytes: all.reduce((a, p) => a + p.tx, 0),
link: {
rx_bytes: linkCounter("rx_bytes"), tx_bytes: linkCounter("tx_bytes"),
rx_packets: linkCounter("rx_packets"), tx_packets: linkCounter("tx_packets"),
rx_dropped: linkCounter("rx_dropped"), tx_dropped: linkCounter("tx_dropped"),
rx_errors: linkCounter("rx_errors"), tx_errors: linkCounter("tx_errors"),
},
shaping: await shaping.status(),
host: readHost(),
list,
};
}
app.get("/api/metrics", auth, handler(async (_req, res) => {
if (!metricsCache || Date.now() - metricsCache.at > METRICS_TTL_MS) {
const data = collectMetrics();
metricsCache = { at: Date.now(), data };
data.catch(() => { metricsCache = null; }); // ошибку не кэшируем
}
res.json(await metricsCache.data);
}));
// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие одно из двух и без
// параметров: awg-ctrl запускает ту же команду, что оператор набрал бы руками (`awg-ctrl monitor install|remove`,
// root), так что код один. Панель сама пакетов не ставит и не удаляет — она только просит об этом здесь.
const MONITOR_CLI = process.env.AWG_CLI ?? "/usr/local/bin/awg-ctrl";
const MONITOR_LOG = process.env.MONITOR_LOG ?? path.resolve(process.cwd(), "..", "logs", "monitor-job.log");
type MonitorAction = "install" | "remove";
let monitorJob: { action: MonitorAction; startedAt: number; exit: number | null } | null = null;
const logTail = (): string => {
try {
const t = readFileSync(MONITOR_LOG, "utf8").slice(-4000);
// apt рисует прогресс возвратом каретки и цветами — в панели это мусор
return t.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\r/g, "\n");
} catch { return ""; }
};
function startMonitorJob(action: MonitorAction, res: Response) {
if (monitorJob && monitorJob.exit === null) { res.status(409).json({ error: "Операция уже идёт" }); return; }
if (!existsSync(MONITOR_CLI)) throw new HttpError(501, `CLI не найден (${MONITOR_CLI}): выполните на сервере awg-ctrl monitor ${action}`);
mkdirSync(path.dirname(MONITOR_LOG), { recursive: true });
const fd = openSync(MONITOR_LOG, "w");
const job = { action, startedAt: Date.now(), exit: null as number | null };
monitorJob = job;
const done = (code: number) => { job.exit = code; try { closeSync(fd); } catch { /* уже закрыт */ } };
const child = spawn(MONITOR_CLI, ["monitor", action], { stdio: ["ignore", fd, fd] });
child.on("error", e => { logger.error("monitor: не запустился", { action, error: e.message }); done(127); });
child.on("exit", code => { logger.info("monitor: завершён", { action, code }); done(code ?? 1); });
res.status(202).json({ running: true, action });
}
app.post("/api/monitor/install", auth, handler((_req, res) => { startMonitorJob("install", res); }));
app.post("/api/monitor/remove", auth, handler((_req, res) => { startMonitorJob("remove", res); }));
app.get("/api/monitor/job", auth, handler((_req, res) => {
res.json({
action: monitorJob?.action ?? null,
running: !!monitorJob && monitorJob.exit === null,
exit: monitorJob?.exit ?? null,
log: monitorJob ? logTail() : "",
});
}));
app.get("/awg/status", auth, handler((_req, res) => {
res.json(getInterfaceStatus());
}));
@@ -1135,3 +1407,7 @@ app.listen(SERVER.port, "127.0.0.1", () => {
});
ensureInterfaceUp();
syncPeers();
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
void shaping.apply();
shaping.start();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.3.1",
"version": "0.5.0",
"description": "",
"main": "dist/index.js",
"scripts": {
+338
View File
@@ -0,0 +1,338 @@
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
//
// Схема:
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
//
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому apply() зовётся
// на старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
//
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по
// времени, ни по неактивности (правило проекта: удаление только по явному списку
// от панели).
import { execFile, spawn } from "child_process";
import type { Logger } from "winston";
export interface ShapingSettings {
enabled: boolean;
downMbit: number;
upMbit: number;
auto: boolean;
}
export interface PeerCounters {
ip: string;
rx: number; // принято от клиента (его отдача)
tx: number; // отправлено клиенту (его загрузка)
}
export interface ShapingDeps {
iface: string;
getCfg: (key: string, fallback: string) => string;
setCfg: (key: string, value: string) => void;
logger: Pick<Logger, "info" | "warn" | "error">;
// Байт на пакет, которые туннель добавляет на проводе (CAKE видит только внутренний IP-пакет).
overhead: () => number;
peers: () => Promise<PeerCounters[]>;
}
export interface QdiscStats { packets: number; bytes: number; drops: number; backlog: number }
export interface ShapingStatus {
settings: ShapingSettings;
active: boolean; // CAKE реально стоит на интерфейсе
congested: boolean;
heavy: string[]; // IP устройств в bulk-классе
devices: number; // активных устройств в окне
rateDown: number; // Мбит/с за последний тик
rateUp: number;
down?: QdiscStats;
up?: QdiscStats;
}
// Окна и пороги. Переопределение через env — только для тестов на стенде.
const num = (v: string | undefined, d: number) => (v && Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
const SAMPLE_S = num(process.env.SHAPE_SAMPLE_S, 10);
const WINDOW_S = num(process.env.SHAPE_WINDOW_S, 600);
const CONGEST_ON = 0.85; // доля ширины, выше которой канал считается перегруженным
const CONGEST_OFF = 0.70; // и ниже которой перегрузка снимается (гистерезис)
const ACTIVE_MBIT = 0.1; // меньше — устройство не считается активным
const HEAVY_MULT = 1.5; // тяжёлое: ≥ 1.5 честных долей…
const HEAVY_MIN_FRAC = 0.05; // …и не меньше 5% ширины канала
const CHECK_EVERY = 3; // страховочная проверка qdisc — раз в N тиков
const IFB = "ifb-awg";
const NFT_TABLE = "awg_shape";
function exec(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { encoding: "utf8", timeout: 15_000 }, (err, stdout, stderr) => {
if (err) reject(new Error(`${cmd} ${args.join(" ")}: ${(stderr || err.message).toString().trim()}`));
else resolve(stdout);
});
});
}
function execStdin(cmd: string, args: string[], input: string): Promise<void> {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ["pipe", "ignore", "pipe"] });
let err = "";
p.stderr.on("data", d => { err += d; });
p.stdin.on("error", () => undefined); // процесс умер раньше, чем дочитал stdin — причина придёт в close
p.on("error", reject);
p.on("close", code => code === 0 ? resolve() : reject(new Error(`${cmd}: ${err.trim() || `exit ${code}`}`)));
p.stdin.end(input);
});
}
interface Tick { dt: number; bytes: number }
export function createShaping(deps: ShapingDeps) {
const { iface, logger } = deps;
// ───────────── настройки ─────────────
function settings(): ShapingSettings {
return {
enabled: deps.getCfg("shapeEnabled", "0") === "1",
downMbit: Number(deps.getCfg("shapeDownMbit", "0")) || 0,
upMbit: Number(deps.getCfg("shapeUpMbit", "0")) || 0,
auto: deps.getCfg("shapeAuto", "1") === "1",
};
}
function save(s: ShapingSettings) {
deps.setCfg("shapeEnabled", s.enabled ? "1" : "0");
deps.setCfg("shapeDownMbit", String(s.downMbit));
deps.setCfg("shapeUpMbit", String(s.upMbit));
deps.setCfg("shapeAuto", s.auto ? "1" : "0");
}
// ───────────── применение ─────────────
// Все изменения tc/nft идут строго по одному: два параллельных apply() дали бы
// полусобранные qdisc.
let chain: Promise<unknown> = Promise.resolve();
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
const r = chain.then(fn, fn);
chain = r.catch(() => undefined);
return r;
};
let lastError = "";
const noteError = (e: unknown) => {
const m = e instanceof Error ? e.message : String(e);
if (m !== lastError) logger.warn("shaping: " + m); // одинаковую ошибку в лог не спамим
lastError = m;
};
const cakeOverhead = () => {
const o = Number(process.env.SHAPE_OVERHEAD);
return Number.isFinite(o) && o >= 0 && process.env.SHAPE_OVERHEAD ? o : deps.overhead();
};
async function nftApply(heavy: string[]) {
const elements = heavy.length ? `\n elements = { ${heavy.join(", ")} }` : "";
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
await execStdin("nft", ["-f", "-"], `
add table inet ${NFT_TABLE}
delete table inet ${NFT_TABLE}
table inet ${NFT_TABLE} {
set heavy {
type ipv4_addr${elements}
}
chain postrouting {
type filter hook postrouting priority -150; policy accept;
oifname "${iface}" ip daddr @heavy ip dscp set cs1
}
}
`);
}
async function hasCake(dev: string): Promise<boolean> {
try { return /\bcake\b/.test(await exec("tc", ["qdisc", "show", "dev", dev, "root"])); }
catch { return false; }
}
async function hasIngressRedirect(): Promise<boolean> {
try { return /mirred/.test(await exec("tc", ["filter", "show", "dev", iface, "parent", "ffff:"])); }
catch { return false; }
}
async function applyNow() {
const s = settings();
if (!s.enabled || s.downMbit <= 0 || s.upMbit <= 0) { await removeNow(); return; }
const oh = cakeOverhead();
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
await exec("tc", ["qdisc", "replace", "dev", iface, "root", "cake",
"bandwidth", `${s.downMbit}mbit`, "diffserv3", "dual-dsthost", "overhead", String(oh)]);
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
try { await exec("ip", ["link", "show", IFB]); }
catch { await exec("ip", ["link", "add", IFB, "type", "ifb"]); }
await exec("ip", ["link", "set", IFB, "up"]);
await exec("tc", ["qdisc", "replace", "dev", IFB, "root", "cake",
"bandwidth", `${s.upMbit}mbit`, "besteffort", "dual-srchost", "overhead", String(oh)]);
if (!(await hasIngressRedirect())) {
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
await exec("tc", ["qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"]);
await exec("tc", ["filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
"matchall", "action", "mirred", "egress", "redirect", "dev", IFB]);
}
await nftApply([...heavySet]);
lastError = "";
logger.info("shaping applied", { down: s.downMbit, up: s.upMbit, overhead: oh, heavy: heavySet.size });
}
async function removeNow() {
await exec("tc", ["qdisc", "del", "dev", iface, "root"]).catch(() => undefined);
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
await exec("ip", ["link", "del", IFB]).catch(() => undefined);
await exec("nft", ["delete", "table", "inet", NFT_TABLE]).catch(() => undefined);
heavySet.clear(); history.clear(); totals.length = 0; congested = false;
}
// Не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от ensureInterfaceUp).
function apply(): Promise<void> {
return serial(applyNow).catch(noteError);
}
async function update(s: ShapingSettings): Promise<void> {
save(s);
await serial(applyNow); // пользователю нужна честная ошибка, а не молчаливый warn
lastError = "";
}
// ───────────── сэмплер (RFC 6057) ─────────────
const prev = new Map<string, { rx: number; tx: number }>();
const history = new Map<string, Tick[]>(); // загрузка по IP
const totals: Tick[] = []; // суммарная загрузка (всё, что ушло клиентам)
const heavySet = new Set<string>();
let congested = false;
let lastRateDown = 0, lastRateUp = 0;
let lastTickAt = 0;
let tickNo = 0;
const windowTicks = Math.max(3, Math.round(WINDOW_S / SAMPLE_S));
const push = (arr: Tick[], t: Tick) => { arr.push(t); while (arr.length > windowTicks) arr.shift(); };
const mbit = (arr: Tick[]) => {
const dt = arr.reduce((a, t) => a + t.dt, 0);
return dt > 0 ? arr.reduce((a, t) => a + t.bytes, 0) * 8 / dt / 1e6 : 0;
};
async function tick() {
const s = settings();
if (!s.enabled) return;
const now = Date.now();
const dt = lastTickAt ? (now - lastTickAt) / 1000 : SAMPLE_S;
lastTickAt = now;
tickNo++;
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
if (tickNo % CHECK_EVERY === 0 && !(await hasCake(iface))) {
logger.warn("shaping: qdisc на интерфейсе пропал, применяю заново");
await apply();
}
let peers: PeerCounters[];
try { peers = await deps.peers(); } catch (e) { noteError(e); return; }
let totalDown = 0, totalUp = 0;
const seen = new Set<string>();
for (const p of peers) {
seen.add(p.ip);
const before = prev.get(p.ip);
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира → отрицательная дельта = 0.
const dTx = before && p.tx >= before.tx ? p.tx - before.tx : 0;
const dRx = before && p.rx >= before.rx ? p.rx - before.rx : 0;
prev.set(p.ip, { rx: p.rx, tx: p.tx });
totalDown += dTx; totalUp += dRx;
let h = history.get(p.ip);
if (!h) history.set(p.ip, h = []);
push(h, { dt, bytes: dTx });
}
for (const ip of [...prev.keys()]) if (!seen.has(ip)) { prev.delete(ip); history.delete(ip); heavySet.delete(ip); }
push(totals, { dt, bytes: totalDown });
lastRateDown = totalDown * 8 / dt / 1e6;
lastRateUp = totalUp * 8 / dt / 1e6;
if (!s.auto) {
if (heavySet.size || congested) { heavySet.clear(); congested = false; await serial(() => nftApply([])).catch(noteError); }
return;
}
if (totals.length < windowTicks) return; // пока окно не набралось, решений не принимаем
const avg = mbit(totals);
const wasCongested = congested;
if (!congested && avg >= CONGEST_ON * s.downMbit) congested = true;
if ( congested && avg < CONGEST_OFF * s.downMbit) congested = false;
const nextHeavy = new Set<string>();
let active = 0;
if (congested) {
const rates = [...history.entries()].filter(([, h]) => h.length >= windowTicks).map(([ip, h]) => [ip, mbit(h)] as const);
active = rates.filter(([, r]) => r >= ACTIVE_MBIT).length;
const share = s.downMbit / Math.max(1, active);
for (const [ip, r] of rates) {
const wasHeavy = heavySet.has(ip);
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
if (r >= Math.max(HEAVY_MULT * share, HEAVY_MIN_FRAC * s.downMbit) || (wasHeavy && r >= share)) nextHeavy.add(ip);
}
}
const changed = nextHeavy.size !== heavySet.size || [...nextHeavy].some(ip => !heavySet.has(ip));
if (changed || wasCongested !== congested) {
heavySet.clear(); nextHeavy.forEach(ip => heavySet.add(ip));
logger.info("shaping: класс тяжёлых обновлён", { congested, avgMbit: Math.round(avg), heavy: [...heavySet] });
await serial(() => nftApply([...heavySet])).catch(noteError);
}
}
let timer: NodeJS.Timeout | null = null;
function start() {
if (timer) return;
let busy = false;
timer = setInterval(() => {
if (busy) return; // предыдущий тик ещё идёт — пропускаем, а не копим очередь
busy = true;
tick().catch(noteError).finally(() => { busy = false; });
}, SAMPLE_S * 1000);
timer.unref();
}
// ───────────── состояние ─────────────
async function qdiscStats(dev: string): Promise<QdiscStats | undefined> {
try {
const arr = JSON.parse(await exec("tc", ["-s", "-j", "qdisc", "show", "dev", dev, "root"])) as Record<string, unknown>[];
const q = arr.find(x => x.kind === "cake");
if (!q) return undefined;
return {
packets: Number(q.packets) || 0, bytes: Number(q.bytes) || 0,
drops: Number(q.drops) || 0, backlog: Number(q.backlog) || 0,
};
} catch { return undefined; }
}
async function status(): Promise<ShapingStatus> {
const s = settings();
const [active, down, up] = s.enabled
? await Promise.all([hasCake(iface), qdiscStats(iface), qdiscStats(IFB)])
: [false, undefined, undefined];
return {
settings: s, active, congested, heavy: [...heavySet],
devices: [...history.values()].filter(h => mbit(h) >= ACTIVE_MBIT).length,
rateDown: Math.round(lastRateDown * 10) / 10,
rateUp: Math.round(lastRateUp * 10) / 10,
down, up,
};
}
// Текущий приоритет устройства по его внутреннему IP: «фоновый» — пока он в bulk-классе.
const priorityOf = (ip: string): "normal" | "background" => (heavySet.has(ip) ? "background" : "normal");
return { settings, update, apply, start, status, priorityOf };
}
export type Shaping = ReturnType<typeof createShaping>;
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AWG Panel</title>
<title>Вход</title>
</head>
<body>
<div id="root"></div>
+235
View File
@@ -0,0 +1,235 @@
// 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.
//
// Prometheus: GET /metrics на ядре. Метрики ядра и всех нод собираются здесь же —
// у нод через уже открытый WSS (awg-agent пропускает только /api/*, поэтому на самих
// нодах отдельного порта для скрейпа не нужно). Доступ — отдельный токен «только
// чтение метрик»; он не открывает ни /api/v1, ни панель.
import crypto from "crypto";
import fs from "fs";
import { Router, type Request, type Response, type NextFunction } from "express";
import type Database from "better-sqlite3";
interface ServerInfo {
id: number; name: string; local: boolean; online: boolean; pending: boolean;
version: string | null;
health: { gen: string; module: string; tools: string } | null;
}
interface MetricsDeps {
uidb: Database.Database;
version: string;
servers: {
list: () => ServerInfo[];
isOnline: (id: number) => boolean;
ctrlFor: (id: number) => (method: string, urlPath: string) => Promise<{ status: number; data: any }>;
};
}
const SCRAPE_DEADLINE_MS = 5_000;
const TOUCH_EVERY_S = 60; // last_used пишем не чаще раза в минуту, а не на каждый скрейп
const hashToken = (t: string) => crypto.createHash("sha256").update(t).digest();
// Токен встроенного Prometheus (`awg-ctrl monitor install`): сам токен лежит только у Prometheus,
// здесь — его хэш. Файл читаем при каждой проверке, пересчитывая по mtime: установка не требует перезапуска панели.
const LOCAL_HASH_FILE = process.env.MONITOR_HASH_FILE ?? "/etc/amnezia/amneziawg/monitor_token.sha256";
let localHash: { mtime: number; hash: Buffer | null } = { mtime: -1, hash: null };
function localTokenHash(): Buffer | null {
try {
const mtime = fs.statSync(LOCAL_HASH_FILE).mtimeMs;
if (mtime !== localHash.mtime) {
const hex = fs.readFileSync(LOCAL_HASH_FILE, "utf8").trim();
localHash = { mtime, hash: /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null };
}
return localHash.hash;
} catch { localHash = { mtime: -1, hash: null }; return null; }
}
// Пер-пировые серии (метка peer = внутренний IP, name = имя vpn://-пользователя) выключены:
// это и приватность, и кардинальность. Включается явно.
const PER_PEER = (process.env.METRICS_PEERS ?? "off").toLowerCase() === "on";
type Labels = Record<string, string | number>;
type Sample = [Labels, number];
const esc = (v: string | number) => String(v).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
const fmtLabels = (l: Labels) => {
const parts = Object.entries(l).map(([k, v]) => `${k}="${esc(v)}"`);
return parts.length ? `{${parts.join(",")}}` : "";
};
class Exposition {
private lines: string[] = [];
private seen = new Set<string>();
add(name: string, type: "gauge" | "counter", help: string, samples: Sample[]) {
if (!samples.length) return;
if (!this.seen.has(name)) {
this.seen.add(name);
this.lines.push(`# HELP ${name} ${help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n")}`, `# TYPE ${name} ${type}`);
}
for (const [l, v] of samples) this.lines.push(`${name}${fmtLabels(l)} ${Number.isFinite(v) ? v : 0}`);
}
toString() { return this.lines.join("\n") + "\n"; }
}
export function createMetrics(deps: MetricsDeps) {
const { uidb } = deps;
uidb.exec(`
CREATE TABLE IF NOT EXISTS metrics_token (
id INTEGER PRIMARY KEY CHECK (id = 1),
token_hash BLOB NOT NULL,
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER
)
`);
const q = {
get: uidb.prepare("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1"),
set: uidb.prepare("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)"),
del: uidb.prepare("DELETE FROM metrics_token WHERE id = 1"),
touch: uidb.prepare("UPDATE metrics_token SET last_used = ? WHERE id = 1"),
};
type TokenRow = { token_hash: Buffer; prefix: string; created_at: number; last_used: number | null };
// ── Управление токеном (роуты панели, за JWT) ──
const router = Router();
const view = () => {
const r = q.get.get() as TokenRow | undefined;
return r ? { exists: true, prefix: r.prefix, created_at: r.created_at, last_used: r.last_used, per_peer: PER_PEER }
: { exists: false, per_peer: PER_PEER };
};
router.get("/", (_req: Request, res: Response) => { res.json(view()); });
// Выпуск и перевыпуск — одно и то же: старый токен перестаёт работать. Значение отдаётся один раз.
router.post("/", (_req: Request, res: Response) => {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
q.set.run(hashToken(token), token.slice(0, 13) + "…", Math.floor(Date.now() / 1000));
res.status(201).json({ ...view(), token });
});
router.delete("/", (_req: Request, res: Response) => { q.del.run(); res.json(view()); });
// ── Доступ к /metrics ──
// Нет токена или он неверный — одинаковый 401: не подсказываем, выключены метрики или токен не тот.
function requireToken(req: Request, res: Response, next: NextFunction) {
const h = req.headers.authorization ?? "";
const given = h.startsWith("Bearer ") ? h.slice(7) : "";
const row = q.get.get() as TokenRow | undefined;
const gh = given.startsWith("awgm_") ? hashToken(given) : null;
const panelOk = !!row && !!gh && crypto.timingSafeEqual(gh, row.token_hash);
const local = localTokenHash();
const localOk = !!gh && !!local && crypto.timingSafeEqual(gh, local);
if (!panelOk && !localOk) { res.status(401).set("WWW-Authenticate", "Bearer").type("text/plain").send("unauthorized\n"); return; }
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
const now = Math.floor(Date.now() / 1000);
if (panelOk && row && (!row.last_used || now - row.last_used >= TOUCH_EVERY_S)) q.touch.run(now);
next();
}
// Один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
async function scrapeServer(s: ServerInfo): Promise<any | null> {
if (s.pending || !deps.servers.isOnline(s.id)) return null;
let timer: NodeJS.Timeout | undefined;
try {
const r = await Promise.race([
deps.servers.ctrlFor(s.id)("GET", "/api/metrics"),
new Promise<never>((_, rej) => { timer = setTimeout(() => rej(new Error("deadline")), SCRAPE_DEADLINE_MS); }),
]);
return r.status === 200 ? r.data : null;
} catch { return null; }
finally { clearTimeout(timer); }
}
async function render(): Promise<string> {
const t0 = process.hrtime.bigint();
const list = deps.servers.list();
const data = await Promise.all(list.map(scrapeServer));
const out = new Exposition();
const L = (s: ServerInfo, extra: Labels = {}): Labels => ({ server: s.name, server_id: s.id, ...extra });
// Серии только тех серверов, чьи метрики получены; fn всегда отдаёт массив серий.
const per = (fn: (s: ServerInfo, d: any) => Sample[]): Sample[] =>
list.flatMap((s, i) => (data[i] ? fn(s, data[i]) : []));
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", [[{ version: deps.version }, 1]]);
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", [[{}, list.length]]);
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", list.map(s => [L(s), s.online ? 1 : 0] as Sample));
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", list.map((s, i) => [L(s), data[i] ? 1 : 0] as Sample));
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.",
list.filter(s => !s.pending).map(s => [L(s, { version: s.version ?? "", gen: s.health?.gen ?? "", module: s.health?.module ?? "", tools: s.health?.tools ?? "" }), 1] as Sample));
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per((s, d) => [[L(s), d.up ? 1 : 0] as Sample]));
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per((s, d) => [[L(s), d.peers?.total ?? 0] as Sample]));
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per((s, d) => [[L(s), d.peers?.online ?? 0] as Sample]));
// Сумма по пирам — gauge, а не counter: при удалении пира или смене его ключа сумма уменьшается,
// и rate() принял бы это за сброс счётчика с ложным всплеском. Для скорости берите awg_link_*_bytes_total.
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.rx_bytes ?? 0] as Sample]));
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.tx_bytes ?? 0] as Sample]));
for (const [k, name, help] of [
["rx_bytes", "receive_bytes", "байт"], ["tx_bytes", "transmit_bytes", "байт"],
["rx_packets", "receive_packets", "пакетов"], ["tx_packets", "transmit_packets", "пакетов"],
["rx_dropped", "receive_dropped", "потерянных пакетов"], ["tx_dropped", "transmit_dropped", "потерянных пакетов"],
["rx_errors", "receive_errors", "ошибок"], ["tx_errors", "transmit_errors", "ошибок"],
] as const) {
out.add(`awg_link_${name}_total`, "counter", `Интерфейс awg1: ${help} (${k}).`, per((s, d) => [[L(s), d.link?.[k] ?? 0] as Sample]));
}
// Нагрузка хоста (ядро и ноды). CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
per((s, d) => Object.entries(d.host?.cpu ?? {}).map(([mode, v]) => [L(s, { mode }), Number(v)] as Sample)));
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per((s, d) => d.host ? [[L(s), d.host.cores ?? 0] as Sample] : []));
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.total ?? 0] as Sample] : []));
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.available ?? 0] as Sample] : []));
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).",
per((s, d) => d.host ? (["1m", "5m", "15m"] as const).map((p, i) => [L(s, { period: p }), d.host.load?.[i] ?? 0] as Sample) : []));
// Деление канала (CAKE)
const sh = (fn: (d: any) => number) => per((s, d) => d.shaping ? [[L(s), fn(d.shaping)] as Sample] : []);
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(x => x.settings?.enabled ? 1 : 0));
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(x => x.active ? 1 : 0));
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(x => x.congested ? 1 : 0));
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(x => x.heavy?.length ?? 0));
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(x => x.devices ?? 0));
const dirs = (fn: (x: any, dir: "down" | "up") => number | undefined) =>
per((s, d) => d.shaping ? (["down", "up"] as const).flatMap(dir => {
const v = fn(d.shaping, dir);
return v === undefined ? [] : [[L(s, { direction: dir }), v] as Sample];
}) : []);
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs((x, dir) => (dir === "down" ? x.settings?.downMbit : x.settings?.upMbit) * 1e6));
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs((x, dir) => (dir === "down" ? x.rateDown : x.rateUp) * 1e6));
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs((x, dir) => x[dir]?.drops));
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs((x, dir) => x[dir]?.backlog));
if (PER_PEER) {
const peers = (fn: (p: any) => number) => per((s, d) => (d.list ?? []).map((p: any) =>
[L(s, { peer: p.ip, name: p.name ?? "", kind: p.kind }), fn(p)] as Sample));
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(p => p.online ? 1 : 0));
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(p => p.priority === "background" ? 1 : 0));
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(p => p.rx));
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(p => p.tx));
}
let masters = 0, devices = 0;
// Таблицы создаёт sub: готовим запросы здесь, а не при создании модуля, — порядок
// инициализации в server.ts не должен решать, упадёт ли панель на старте.
try {
masters = (uidb.prepare("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").get() as { n: number }).n;
devices = (uidb.prepare("SELECT COUNT(*) AS n FROM devices").get() as { n: number }).n;
} catch { /* sub ещё не создал таблицы */ }
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", [[{}, masters]]);
out.add("awg_devices", "gauge", "Устройств sen://.", [[{}, devices]]);
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", [[{}, Number(process.hrtime.bigint() - t0) / 1e9]]);
return out.toString();
}
async function handler(_req: Request, res: Response) {
try {
res.type("text/plain; version=0.0.4; charset=utf-8").send(await render());
} catch (e) {
console.warn(`metrics: ${(e as Error).message}`);
res.status(500).type("text/plain").send("error\n");
}
}
return { router, requireToken, handler };
}
+348 -23
View File
@@ -15,6 +15,7 @@ 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.5.0";
// ── пользователи (vpn://) ──
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
@@ -34,29 +35,39 @@ const usersOf: Record<number, U[]> = {
};
const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; };
const U_ = (q: express.Request) => usersOf[srv(q)];
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean }
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 },
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true },
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.5.0" },
{ 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: false, pending: false, version: "0.4.5" }, // старше 0.5.0: хаб не подключает
{ id: 4, name: "Helsinki", ip: "192.0.2.44", gen: "3.1", online: true, pending: false, version: "0.5.1" }, // новее панели — жёлтый чип
];
let nodeId = 2;
let nodeId = 3;
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
// ── API-ключи ──
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }];
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, servers: [0], created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }];
let apiId = 1;
// ── мастер-ключи sen:// ──
interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number }
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[] }
interface MK { id: number; 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++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")] }];
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 2, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1, 2] }, // все места заняты; Tokyo (2) ещё не получил устройства
{ id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0, 1] }, // «все серверы»
{ id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }];
const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, server_id: 0, created_at: m.created_at });
const find = (id: string) => masters.find(m => m.id === Number(id));
const mkOut = (m: MK) => ({ id: m.id, 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 devTorrent = (d: Dev) => !!torrentOf[0]?.enabled && d.id === masters[0]?.devs[1]?.id;
const devOut = (m: MK, d: Dev) => ({ ...d,
priority: shapingOf[0]?.enabled && (d.id === masters[0]?.devs[0]?.id || devTorrent(d)) ? "background" : "normal",
torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length,
ips: m.servers.filter(s => s !== 2).map(s => ({ server_id: s, ip: `10.9.0.${50 + d.id + s * 10}` })) });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (q, r) => {
@@ -65,25 +76,286 @@ app.get("/health", (q, r) => {
});
// ── серверы ──
// Как MinNodeVersion в хабе: ноду старше хаб не подключает (в моке она просто offline + outdated)
const MIN_NODE = "0.5.0";
const cmpVer = (a: string, b: string) => {
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;
};
const nodeView = () => [
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null,
{ 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,
outdated: !n.pending && (!n.version || cmpVer(n.version, MIN_NODE) < 0),
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
];
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() }));
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
app.post("/ui/nodes", (q, r) => {
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 });
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 i = mnodes.findIndex(x => x.id === Number(q.params.id)); if (i < 0) return r.status(404).json({ error: "Не найден" }); mnodes.splice(i, 1); r.json({ success: true }); });
app.delete("/ui/nodes/:id", (q, r) => {
const id = Number(q.params.id), i = mnodes.findIndex(x => x.id === id); if (i < 0) return r.status(404).json({ error: "Не найден" });
if (masters.some(m => !m.deleting && m.servers.includes(id))) return r.status(409).json({ error: "Сервер в мастер-ключах — сначала уберите его оттуда", can_force: false });
const waiting = masters.filter(m => m.deleting?.includes(id));
if (waiting.length && q.query.force !== "1")
return r.status(409).json({ error: `Сервер не подтвердил, что снял пиров удалённых мастер-ключей (${waiting.map(m => m.label).join(", ")})`, can_force: true });
for (const m of waiting) m.deleting = m.deleting!.filter(x => x !== id);
for (const m of waiting) if (!m.deleting!.length) masters.splice(masters.indexOf(m), 1);
mnodes.splice(i, 1); r.json({ success: true });
});
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
// Монитор: «установка» и «удаление» Prometheus с ходом процесса (как лог CLI на реальном сервере).
const MON_LOGS = {
install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 31d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."],
remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."],
};
// Как internal/ui/monitor.go: Prometheus у панели один. target — чей awg-ctrl ставит и удаляет его:
// где он уже стоит; если нигде — свой (панель + VPN) или ?server= (панель без VPN, MOCK_ROLE=core).
// Ход операции у каждого сервера свой (его awg-ctrl). Ставить можно, только если awg-ctrl сервера
// на связи и новый: Tokyo без связи, у Amsterdam старый агент (/api/monitor/job → 404) — у них
// can_install: false и команда вручную; у Frankfurt осталась неудачная прошлая установка.
type MonJob = { action: "install" | "remove"; start: number; lines?: string[]; exit?: number };
const monJobs: Record<number, MonJob> = {
1: { action: "install", start: now() * 1000 - 86400e3, exit: 100, lines: ["1/4 установка пакета prometheus", "Reading package lists…",
"E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1873 (unattended-upgr)", "E: Unable to acquire the dpkg frontend lock, is another process using it?"] },
};
let monReady = false;
let monHost = 0; // где стоит (для панели без VPN — нода; у панели с VPN всегда 0, «этот сервер»)
const monHostOf = (id: number) => ({ id, name: nodeView().find(n => n.id === id)?.name ?? `#${id}` });
const MON_MS = 6000;
const monRunning = (j?: MonJob) => !!j && j.exit === undefined && Date.now() - j.start < MON_MS;
const monTarget = (q: express.Request) => monReady || !MOCK_CORE ? monHost : Number(q.query.server ?? 0);
const monCan = (id: number) => { const n = mnodes.find(x => x.id === id); return !n || (n.online && n.version !== null); };
app.get("/ui/monitor", (q, r) => {
const target = monTarget(q), can = monCan(target), mj = monJobs[target];
if (mj && mj.exit === undefined && !monRunning(mj)) { // операция дошла до конца
mj.exit = 0;
monReady = mj.action === "install";
if (monReady) monHost = target;
}
let job = null;
if (can && mj) {
const lines = mj.lines ?? MON_LOGS[mj.action];
const n = monRunning(mj) ? Math.max(1, Math.floor(((Date.now() - mj.start) / MON_MS) * lines.length)) : lines.length;
job = { action: mj.action, running: monRunning(mj), exit: monRunning(mj) ? null : mj.exit ?? 0, log: lines.slice(0, n).join("\n") };
}
r.json({ ready: monReady, can_install: can, command: "awg-ctrl monitor install", host: MOCK_CORE ? monHostOf(target) : null, job });
});
for (const action of ["install", "remove"] as const) {
app.post(`/ui/monitor/${action}`, (q, r) => {
const target = monTarget(q), n = mnodes.find(x => x.id === target);
if (n && !n.online) return r.status(502).json({ error: "awg-ctrl недоступен" });
if (n && n.version === null) return r.status(501).json({ error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" });
if (!monRunning(monJobs[target])) monJobs[target] = { action, start: Date.now() };
r.status(202).json({ running: true, action });
});
}
// Перенос панели (internal/ui/migrate.go). Мок — standalone: переносить можно, принимать нельзя;
// MOCK_ROLE=core — наоборот (код «принимается» через 10 с, ещё через 10 с — «перезапуск»);
// MOCK_ROLE=retire — панель уже приняла перенос: готовность к отключению старого сервера.
const MOCK_CORE = process.env.MOCK_ROLE === "core" || process.env.MOCK_ROLE === "retire";
let mv: { state: string; error: string | null; target: string | null; code: { code: string; at: number } | null; movedAt: number } =
{ state: "", error: null, target: null, code: null, movedAt: process.env.MOCK_ROLE === "retire" ? Date.now() : 0 };
const mvRetire = () => {
if (!mv.movedAt) return null;
const t = (Date.now() - mv.movedAt) / 1000; // клиенты и ноды «узнают» адрес со временем
return { since: Math.floor(mv.movedAt / 1000), home: "198.51.100.20:40500",
old: { exists: true, online: true },
devices: { total: 12, fresh: Math.min(12, 7 + Math.floor(t / 15)) }, unused_links: 1,
nodes: [{ id: 1, name: "Frankfurt", online: true, ok: true }, { id: 2, name: "Tokyo", online: false, ok: false },
{ id: 4, name: "Amsterdam", online: true, ok: t > 20 }] };
};
const mvView = () => {
if (mv.code && Date.now() - mv.code.at > 10000) mv = { ...mv, state: "imported", code: null, movedAt: Date.now() };
if (mv.state === "imported" && Date.now() - mv.movedAt > 10000) mv = { ...mv, state: "" };
return { state: mv.state, error: mv.error, target: mv.target,
code: mv.code && { code: mv.code.code, expires_in: 3600 - Math.floor((Date.now() - mv.code.at) / 1000) },
...(mv.state === "imported" ? {} : {
export: MOCK_CORE ? "У этой панели нет своего VPN: переносить её как сервер-ноду нечего" : null,
accept: !MOCK_CORE ? "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
: mv.movedAt ? "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую" : null,
retire: mvRetire() }) };
};
app.get("/ui/migrate", (_q, r) => r.json(mvView()));
app.post("/ui/migrate/code", (q, r) => {
if (!MOCK_CORE) return r.status(409).json({ error: "У этой панели свой VPN" });
mv.code = { code: "awgmove://" + Buffer.from(JSON.stringify({ v: 1, h: q.body?.host ?? "127.0.0.1", p: 8080, k: "mock" })).toString("base64url"), at: Date.now() };
r.json(mvView());
});
app.delete("/ui/migrate/code", (_q, r) => { mv.code = null; r.json(mvView()); });
app.post("/ui/migrate/export", (q, r) => {
const code = String(q.body?.code ?? "");
setTimeout(() => {
if (!code.startsWith("awgmove://ey")) return r.status(400).json({ error: "код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели" });
mv = { ...mv, state: "moved", target: "http://198.51.100.20:31337" };
r.json({ ok: true, target: mv.target });
}, 1500);
});
app.post("/ui/migrate/cancel", (_q, r) => { mv = { ...mv, state: "", error: null }; r.json(mvView()); });
app.delete("/ui/migrate/retire", (_q, r) => { mv.movedAt = 0; r.json(mvView()); });
app.get("/ui/monitor/overview", (q, r) => {
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300], "7d": [604800, 1800], "30d": [2592000, 7200] };
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
const to = Math.floor(Date.now() / step) * step, from = to - sec;
const pts = (f: (i: number, t: number) => number): [number, number][] => Array.from({ length: Math.floor(sec / step) + 1 }, (_, i) => [from + i * step, Math.max(0, f(i, from + i * step))]);
const wave = (t: number, per: number, ph = 0) => Math.sin((t / per) * 2 * Math.PI + ph);
const per = sec / 3;
const down = pts((_, t) => 110e6 + 70e6 * wave(t, per) + 25e6 * wave(t, per / 7) + rnd(12e6));
const up = pts((_, t) => 14e6 + 7e6 * wave(t, per, 1) + rnd(3e6));
const online = pts((_, t) => Math.round(9 + 3 * wave(t, per * 1.6) + (rnd(100) > 90 ? 1 : 0)));
const heavy = pts((_, t) => (wave(t, per) > 0.55 ? 1 : 0));
const drops = pts((_, t) => (wave(t, per) > 0.55 ? 3 + rnd(6) : rnd(10) > 8 ? 0.4 : 0));
const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6));
const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2));
const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]);
const used = (a: [number, number][]) => a.reduce((n, [, v]) => n + v, 0) * step / 8; // бит/с × с → байты
// Ряды одного сервера: ядро (0) — основная нагрузка, нода Frankfurt (1) — поменьше
const one = (id: number) => {
const k = id === 0 ? 0.7 : 0.3;
const d = scale(down, k), u = scale(up, k);
return {
row: { id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: d.at(-1)![1], up: u.at(-1)![1], online: id === 0 ? 7 : 3,
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30,
used_down: used(d), used_up: used(u) },
series: { down: d, up: u, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) },
};
};
const empty = { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] };
// Без ?server= — все серверы (как monitor.go): ряды трафика, устройств и потерь суммируются, процессор
// и память — среднее. Метрики отдают ядро и Frankfurt; у Amsterdam (3) старый агент — его нет.
if (q.query.server === undefined) {
const all = [one(0), one(1)];
const zip = (key: keyof typeof empty, avg: boolean) => all[0].series[key].map(([t], n) => {
const sum = all.reduce((acc, x) => acc + x.series[key][n][1], 0);
return [t, avg ? sum / all.length : sum] as [number, number];
});
return r.json({ range: q.query.range ?? "1h", step, from, to, servers: all.map(x => x.row),
total: { down: zip("down", false), up: zip("up", false), online: zip("online", false), heavy: zip("heavy", false),
drops: zip("drops", false), cpu: zip("cpu", true), mem: zip("mem", true) } });
}
// Один сервер (?server=<id>)
const id = Number(q.query.server);
if (id !== 0 && id !== 1) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: empty });
const x = one(id);
r.json({ range: q.query.range ?? "1h", step, from, to, servers: [x.row], total: x.series });
});
// Prometheus: токен метрик и сам /metrics (без проверки токена — мок).
let promTok: { prefix: string; created_at: number; last_used: number | null } | null = null;
const promView = () => promTok ? { exists: true, ...promTok, per_peer: false } : { exists: false, per_peer: false };
app.get("/ui/metrics-token", (_q, r) => r.json(promView()));
app.post("/ui/metrics-token", (_q, r) => {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
promTok = { prefix: token.slice(0, 13) + "…", created_at: now(), last_used: null };
r.status(201).json({ ...promView(), token });
});
app.delete("/ui/metrics-token", (_q, r) => { promTok = null; r.json(promView()); });
app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n'));
// Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»).
// На сервере 0 деление канала включено сразу — чтобы был пример «фонового» приоритета: пользователь
// 10.9.0.2 и «MacBook Ивана» в мастер-ключе «Семья».
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = { 0: { enabled: true, downMbit: 100, upMbit: 50, auto: true } };
const shapingView = (id: number) => {
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
const on = settings.enabled;
return {
settings, active: on, congested: on && id === 0,
heavy: on && id === 0 ? ["10.9.0.2", ...(torrentOf[0]?.enabled ? [TORRENT_IP, "10.9.0.12"] : [])] : [],
devices: on ? 7 : 0, rateDown: on ? Math.round(settings.downMbit * 0.9 * 10) / 10 : 0, rateUp: on ? 12.4 : 0,
};
};
app.get("/api/shaping", (q, r) => srv(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(shapingView(srv(q))));
app.put("/api/shaping", (q, r) => {
const id = srv(q), b = q.body ?? {};
if (id === 1) return r.status(404).json({ error: "Not found" });
if (b.enabled && !(b.downMbit >= 1 && b.upMbit >= 1)) return r.status(400).json({ error: "Для включения укажите ширину канала в обе стороны" });
shapingOf[id] = { enabled: !!b.enabled, downMbit: Number(b.downMbit) || 0, upMbit: Number(b.upMbit) || 0, auto: !!b.auto };
r.json(shapingView(id));
});
// Блокировка торрентов: на сервере 0 качает пользователь 10.9.0.3 и второе устройство первого мастер-ключа;
// Amsterdam (3) изображает старый nftables без TCP-проверки; Frankfurt (1) — старая нода без маршрута (404).
// Сервер берём прямо из X-Server-Id: srv() подменяет сервер без своих пользователей на 0.
const torrentOf: Record<number, { enabled: boolean; since: number }> = {};
const tsid = (q: express.Request) => Number(q.headers["x-server-id"] ?? 0) || 0;
const TORRENT_IP = "10.9.0.3";
const torrentSeenIp = (id: number, ip: string) => id === 0 && !!torrentOf[0]?.enabled && ip === TORRENT_IP;
const torrentView = (id: number) => {
const t = torrentOf[id] ?? { enabled: false, since: 0 };
const seen = id === 0 && t.enabled
? [{ ip: TORRENT_IP, name: users.find(u => u.ip === TORRENT_IP)?.name ?? "" }, { ip: "10.9.0.12", name: "" }]
: [];
return { settings: { enabled: t.enabled }, active: t.enabled, tcp: t.enabled && id !== 3,
hits: t.enabled && id === 0 ? 1840 + Math.floor((now() - t.since) * 3.7) : 0, seen };
};
app.get("/api/torrent", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(torrentView(tsid(q))));
app.put("/api/torrent", (q, r) => {
const id = tsid(q), b = q.body ?? {};
if (id === 1) return r.status(404).json({ error: "Not found" });
if (typeof b.enabled !== "boolean") return r.status(400).json({ error: "enabled должен быть true/false" });
torrentOf[id] = { enabled: b.enabled, since: b.enabled && !torrentOf[id]?.enabled ? now() : (torrentOf[id]?.since ?? now()) };
r.json(torrentView(id));
});
// Маскировка первого пакета (I1): у каждого сервера своя; Frankfurt (1) — старая нода без маршрута (404).
// «Сменить» берёт случайный шаблон из примеров — как GenI1 в awg-ctrl, только без случайных полей.
const I1_SAMPLES = [
{ kind: "dns", label: "DNS-запрос к ya.ru", i1: "<r 2><b 0x01000001000000000001027961027275000001000100002904d0000000000000>" },
{ kind: "quic", label: "QUIC Initial, 1250 байт", i1: "<b 0xc30000000108><r 8><b 0x00><b 0x0044e2><r 1232>" },
{ kind: "stun", label: "STUN binding request", i1: "<b 0x000100002112a442><r 12>" },
{ kind: "dns", label: "DNS-запрос к www.google.com", i1: "<r 2><b 0x010000010000000000000377777706676f6f676c6503636f6d0000410001>" },
{ kind: "quic", label: "QUIC Initial, 1200 байт", i1: "<b 0xc90000000108><r 8><b 0x08><r 8><b 0x0044a6><r 1190>" },
];
const maskingOf: Record<number, number> = { 0: 0, 2: 1, 3: 2 };
app.get("/api/masking", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(I1_SAMPLES[maskingOf[tsid(q)] ?? 3]));
app.post("/api/masking", (q, r) => {
const id = tsid(q);
if (id === 1) return r.status(404).json({ error: "Not found" });
const cur = maskingOf[id] ?? 3;
maskingOf[id] = (cur + 1 + Math.floor(Math.random() * (I1_SAMPLES.length - 1))) % I1_SAMPLES.length;
r.json(I1_SAMPLES[maskingOf[id]]);
});
// Журнал подключений (вкладка «Подключения»): несколько событий за двое суток.
app.get("/ui/connlog", (q, r) => {
if (q.query.before) return r.json({ events: [], peers: [], since: null, keep_days: 30 });
const now = Math.floor(Date.now() / 1000);
const who = [
{ pub_key: "aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9d=", name: "alice", owner: "", ip: "10.9.0.2" },
{ pub_key: "Zx8Cv6Bn4Mq2We0Rt8Yu6Io4Pa2Sd0Fg8Hj6Kl4Zx2C=", name: "", owner: "mk1", ip: "10.9.0.7", key: "Семья", device: "Pixel 7 Pro" },
{ pub_key: "Qw1Er3Ty5Ui7Op9As1Df3Gh5Jk7Lz9Xc1Vb3Nm5Qw7E=", name: "bob", owner: "", ip: "10.9.0.3" },
];
const events = [
{ ...who[0], id: 6, at: now - 300, event: "connect" },
{ ...who[1], id: 5, at: now - 3900, event: "disconnect", duration: 5400, rx: 812e6, tx: 64e6 },
{ ...who[2], id: 4, at: now - 9000, event: "disconnect", duration: 640, rx: 12e6, tx: 2e6 },
{ ...who[2], id: 3, at: now - 9640, event: "connect" },
{ ...who[1], id: 2, at: now - 9300, event: "connect" },
{ ...who[0], id: 1, at: now - 90000, event: "disconnect", duration: 7200, rx: 2.1e9, tx: 190e6 },
];
const peers = [
{ ...who[0], online: true, last_seen: now - 20 },
{ ...who[1], online: false, last_seen: now - 3900 },
{ ...who[2], online: false, last_seen: now - 9000 },
];
r.json({ events, peers, since: now - 3 * 86400, keep_days: 30 });
});
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) }));
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
const prioOf = (q: express.Request, ip: string) =>
(srv(q) === 0 && shapingOf[0]?.enabled && (ip === "10.9.0.2" || torrentSeenIp(tsid(q), ip)) ? "background" : "normal");
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => ({ ...u, priority: prioOf(q, u.ip), torrent: torrentSeenIp(tsid(q), u.ip) })) }));
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);
@@ -97,28 +369,81 @@ app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q.
app.delete("/api/users/:name", (q, r) => { const users = U_(q); const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys }));
app.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
app.post("/ui/apikeys", (q, r) => { const sid = Number(q.body.server_id ?? 0); const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: sid, servers: q.body.servers ?? [sid], created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
app.patch("/ui/apikeys/:id", (q, r) => {
const k = apiKeys.find(x => x.id === Number(q.params.id)); if (!k) return r.status(404).json({ error: "Не найден" });
if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" });
k.servers = [...q.body.servers].sort(); if (!k.servers.includes(k.server_id)) k.server_id = k.servers[0];
r.json({ id: k.id, server_id: k.server_id, servers: k.servers });
});
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
let mockAuditSettings = { clean: false, every_h: 1, max: 10 };
let mockCleaned: unknown[] = [];
let mockGhost = true;
const mockAudit = () => ({ enabled: true, settings: mockAuditSettings, cleaned: mockCleaned, report: { at: Math.floor(Date.now() / 1000) - 600, servers: [
{ server_id: 0, status: "ok", ghosts_checked: true, foreign: 0, orphans: [], ghosts: mockGhost ? [
{ pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", online: true, lastHandshake: Math.floor(Date.now() / 1000) - 30, seen_before: true } ] : [] } ] } });
app.get("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
app.post("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
app.post("/ui/masterkeys/audit/clean", (_q, r) => {
if (mockGhost) mockCleaned = [{ at: Math.floor(Date.now() / 1000), server_id: 0, pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", kind: "ghost", auto: false }, ...mockCleaned];
mockGhost = false; r.json(mockAudit());
});
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
let mkDefaults: number[] | null = null;
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut), defaults: mkDefaults }));
// «Серверы для всех» (sub_admin.go bulkServers): add — у всех, remove — ни у кого.
app.post("/ui/masterkeys/servers", (q, r) => {
const add: number[] = q.body.add ?? [], remove: number[] = q.body.remove ?? [];
if (add.some(x => remove.includes(x))) return r.status(400).json({ error: "Сервер не может быть и у всех, и ни у кого" });
const next = masters.map(m => ({ m, s: [...new Set([...m.servers.filter(x => !remove.includes(x)), ...add])].sort() }));
const empty = next.filter(x => !x.s.length).length;
if (empty) return r.status(409).json({ error: `У ${empty} ключей не останется ни одного сервера — оставьте им хотя бы один` });
const changed = next.filter(x => x.s.join() !== [...x.m.servers].sort().join());
const within = (l: number[], sc: number[]) => l.every(x => sc.includes(x));
const hidden = apiKeys.map(k => ({ id: k.id, label: k.label, keys: changed.filter(x => within(x.m.servers, k.servers) && !within(x.s, k.servers)).length })).filter(h => h.keys);
if (!q.body.dry_run) { changed.forEach(x => { x.m.servers = x.s; }); if ("defaults" in q.body) mkDefaults = q.body.defaults; }
r.json({ changed: changed.length, total: masters.length, hidden });
});
app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3;
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [] };
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? mkDefaults ?? [0] };
masters.push(m); r.status(201).json(mkOut(m));
});
app.patch("/ui/masterkeys/:id", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m)); });
app.patch("/ui/masterkeys/:id", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); }
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit;
// Как 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 i = masters.findIndex(m => m.id === Number(q.params.id)); if (i >= 0) masters.splice(i, 1); r.json({ success: true }); });
app.delete("/ui/masterkeys/:id", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
const pending = m.servers.filter(id => id !== 0 && !mnodes.find(n => n.id === id)?.online);
if (pending.length) { m.deleting = pending; m.devs = []; } else masters.splice(masters.indexOf(m), 1);
r.json({ success: true, id: m.id, pending });
});
app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
});
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs }) : r.status(404).json({ error: "Не найден" }); });
// MOCK_MK_FAIL=<id ключа> — /devices этого ключа отвечает 502; MOCK_MK_DOWN=<id сервера> — этот сервер «не ответил».
app.get("/ui/masterkeys/:id/devices", (q, r) => {
const m = find(q.params.id);
if (!m) return r.status(404).json({ error: "Не найден" });
if (process.env.MOCK_MK_FAIL === String(m.id)) return r.status(502).json({ error: "awg-ctrl недоступен" });
const down = Number(process.env.MOCK_MK_DOWN);
r.json({ devices: m.devs.map(d => devOut(m, d)), unreachable: m.servers.filter(s => s === down) });
});
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
app.post("/ui/devices/:id/psk", (_q, r) => r.json({}));
app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
+195
View File
@@ -0,0 +1,195 @@
// 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.
//
// Вкладка «Монитор»: встроенный Prometheus и основные графики прямо в панели.
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove` (удаление вместе с накопленными данными)
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч
// Панель сама пакетов не ставит и в Prometheus лезет только фиксированными запросами (не PromQL от клиента).
import axios from "axios";
import { Router, type Request, type Response } from "express";
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
const PROM_URL = process.env.PROM_URL ?? "http://127.0.0.1:9090";
// Шаг подбираем так, чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
// Дальше 30 дней не заглядываем: столько Prometheus хранит (RETENTION в cli/src/monitor.ts).
const RANGES: Record<string, { seconds: number; step: number }> = {
"1h": { seconds: 3600, step: 15 },
"6h": { seconds: 21600, step: 60 },
"24h": { seconds: 86400, step: 300 },
"7d": { seconds: 604800, step: 1800 },
"30d": { seconds: 2592000, step: 7200 },
};
// Окно rate() — не короче шага: иначе на неделе/месяце точка была бы случайной двухминутной выборкой
// из двух часов, а не средним за них. Значения «сейчас» при этом считаются отдельно, по окну 2m.
const RATE_WINDOW = "2m";
const withWindow = (q: string, w: string) => q.split(`[${RATE_WINDOW}]`).join(`[${w}]`);
type Point = [number, number];
type Series = Map<number, { name: string; points: Point[] }>; // server_id → ряд
// Запросы по серверам. Трафик берём со счётчиков интерфейса (монотонны), а не с сумм по пирам:
// сумма по пирам падает при удалении пира, и rate() рисовал бы ложный всплеск.
const QUERIES = {
down: 'sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8',
up: 'sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8',
online: 'sum by (server_id, server) (awg_peers_online)',
heavy: 'sum by (server_id, server) (awg_shaping_heavy_devices)',
drops: 'sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))',
congested: 'max by (server_id, server) (awg_shaping_congested)',
scrape_ok: 'max by (server_id, server) (awg_scrape_ok)',
// Загрузка процессора: доля времени не в idle/iowait. Память: MemAvailable, а не MemFree (кэш ядра свободен).
cpu: '100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))',
mem: '100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))',
cores: 'max by (server_id, server) (awg_host_cpu_cores)',
mem_total: 'max by (server_id, server) (awg_host_memory_total_bytes)',
} as const;
type Key = keyof typeof QUERIES;
async function instantQuery(query: string, time: number): Promise<Map<number, number>> {
const r = await axios.get(`${PROM_URL}/api/v1/query`, { params: { query, time }, timeout: 8000 });
const out = new Map<number, number>();
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; value: [number, string] }[]) {
const id = Number(m.metric.server_id), v = Number(m.value[1]);
if (Number.isInteger(id) && Number.isFinite(v)) out.set(id, v);
}
return out;
}
async function rangeQuery(query: string, start: number, end: number, step: number): Promise<Series> {
const r = await axios.get(`${PROM_URL}/api/v1/query_range`, {
params: { query, start, end, step }, timeout: 8000,
});
const out: Series = new Map();
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; values: [number, string][] }[]) {
const id = Number(m.metric.server_id);
if (!Number.isInteger(id)) continue;
out.set(id, {
name: m.metric.server ?? String(id),
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
points: m.values.map(([t, v]) => [t, Number(v)] as Point).filter(p => Number.isFinite(p[1])),
});
}
return out;
}
// Сумма рядов по меткам времени (Prometheus выравнивает точки по шагу, метки совпадают).
function total(series: Series, how: "sum" | "max" = "sum"): Point[] {
const acc = new Map<number, number>();
for (const s of series.values()) for (const [t, v] of s.points)
acc.set(t, how === "sum" ? (acc.get(t) ?? 0) + v : Math.max(acc.get(t) ?? 0, v));
return [...acc.entries()].sort((a, b) => a[0] - b[0]);
}
const last = (s?: { points: Point[] }): number | null => s && s.points.length ? s.points[s.points.length - 1][1] : null;
export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean }) {
const router = Router();
const promReady = async (): Promise<boolean> => {
try { return (await axios.get(`${PROM_URL}/-/ready`, { timeout: 1500, validateStatus: () => true })).status === 200; }
catch { return false; }
};
// Установка возможна, только если рядом свой awg-ctrl (у панели без собственного VPN его нет —
// тогда остаётся команда в терминале) и он достаточно новый, чтобы знать маршрут.
type Job = { can: boolean; action: "install" | "remove" | null; running: boolean; exit: number | null; log: string };
async function jobState(): Promise<Job> {
const none: Job = { can: false, action: null, running: false, exit: null, log: "" };
if (!deps.localEnabled) return none;
try {
const r = await deps.ctrlLocal("GET", "/api/monitor/job");
if (r.status !== 200) return none;
return { can: true, action: r.data.action ?? null, running: !!r.data.running, exit: r.data.exit ?? null, log: String(r.data.log ?? "") };
} catch { return none; }
}
router.get("/", async (_req: Request, res: Response) => {
const [ready, job] = await Promise.all([promReady(), jobState()]);
res.json({
ready,
can_install: job.can,
// Последняя операция (установка или удаление) и её лог; null — ещё не запускали
job: job.can && job.action ? { action: job.action, running: job.running, exit: job.exit, log: job.log } : null,
command: "awg-ctrl monitor install",
});
});
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
const ask = (action: "install" | "remove") => async (_req: Request, res: Response) => {
if (!deps.localEnabled) { res.status(409).json({ error: "У этой панели нет своего VPN-сервера: выполните команду на машине" }); return; }
try {
const r = await deps.ctrlLocal("POST", `/api/monitor/${action}`);
res.status(r.status === 404 ? 501 : r.status).json(
r.status === 404 ? { error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" } : r.data);
} catch { res.status(502).json({ error: "awg-ctrl недоступен" }); }
};
router.post("/install", ask("install"));
router.post("/remove", ask("remove"));
router.get("/overview", async (req: Request, res: Response) => {
const range = RANGES[String(req.query.range ?? "1h")];
if (!range) { res.status(400).json({ error: "range: 1h, 6h, 24h, 7d или 30d" }); return; }
const end = Math.floor(Date.now() / 1000);
const start = end - range.seconds;
try {
const keys = Object.keys(QUERIES) as Key[];
const window = range.step > 120 ? `${range.step}s` : RATE_WINDOW;
const got = await Promise.all(keys.map(k => rangeQuery(withWindow(QUERIES[k], window), start, end, range.step)));
// Окно расширено — «сейчас» с конца ряда было бы средним за шаг; берём мгновенные значения по 2m.
const rated: Key[] = ["down", "up", "cpu"];
const now = window === RATE_WINDOW ? null
: new Map(await Promise.all(rated.map(async k => [k, await instantQuery(QUERIES[k], end)] as const)));
// Вкладка показывает один сервер — тот, что выбран в сервер-баре (?server=<id>).
const only = Number.isInteger(Number(req.query.server)) && req.query.server !== undefined ? Number(req.query.server) : null;
const s = Object.fromEntries(keys.map((k, i) => [k,
only === null ? got[i] : new Map([...got[i]].filter(([id]) => id === only))])) as Record<Key, Series>;
// Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна.
const ids = new Set<number>();
for (const k of keys) for (const id of s[k].keys()) ids.add(id);
// Объём за весь период: increase() по тем же монотонным счётчикам интерфейса (сбросы счётчика он учитывает)
const [usedDown, usedUp] = await Promise.all([
instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[${range.seconds}s]))`, end),
instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[${range.seconds}s]))`, end),
]);
const cur = (k: Key, id: number) => now?.has(k) ? (now.get(k)!.get(id) ?? null) : last(s[k].get(id));
const servers = [...ids].sort((a, b) => a - b).map(id => ({
id,
name: (s.scrape_ok.get(id) ?? s.down.get(id) ?? s.online.get(id))?.name ?? String(id),
ok: last(s.scrape_ok.get(id)) === 1,
down: cur("down", id),
up: cur("up", id),
online: last(s.online.get(id)),
heavy: last(s.heavy.get(id)),
congested: last(s.congested.get(id)) === 1,
cpu: cur("cpu", id),
mem: last(s.mem.get(id)),
cores: last(s.cores.get(id)),
mem_total: last(s.mem_total.get(id)),
used_down: usedDown.get(id) ?? null,
used_up: usedUp.get(id) ?? null,
}));
res.json({
range: req.query.range ?? "1h", step: range.step, from: start, to: end,
servers,
total: {
down: total(s.down), up: total(s.up), online: total(s.online),
heavy: total(s.heavy), drops: total(s.drops),
cpu: total(s.cpu, "max"), mem: total(s.mem, "max"),
},
});
} catch (e) {
// Prometheus стоит, но не ответил: график важнее ошибки, но причину оператору покажем
res.status(502).json({ error: axios.isAxiosError(e) ? "Prometheus не отвечает" : (e as Error).message });
}
});
return { router };
}
+64 -20
View File
@@ -25,7 +25,7 @@ export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<
interface NodeRow {
id: number; name: string; pub: string | null;
join_hash: Buffer | null; join_expires: number | null;
created_at: number; last_seen: number | null;
created_at: number; last_seen: number | null; version: string | null;
}
interface Health {
@@ -46,11 +46,12 @@ 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 }) {
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;
@@ -66,9 +67,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt");
try {
if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) {
// CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
const r = spawnSync("openssl", [
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=" + crypto.randomBytes(6).toString("hex"),
], { stdio: "ignore" });
if (r.status !== 0) throw new Error("openssl");
fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600);
@@ -94,6 +96,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
last_seen INTEGER
);
`);
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают). Колонка
// добавлена позже, у существующей таблицы её может не быть.
if (!(uidb.prepare("PRAGMA table_info(nodes)").all() as { name: string }[]).some(c => c.name === "version"))
uidb.exec("ALTER TABLE nodes ADD COLUMN version TEXT");
const q = {
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
@@ -101,7 +107,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"),
rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"),
bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"),
seen: uidb.prepare<[number, number]>("UPDATE nodes SET last_seen = ? WHERE id = ?"),
seen: uidb.prepare<[number, string | null, number]>("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?"),
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
};
const now = () => Math.floor(Date.now() / 1000);
@@ -138,6 +144,26 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const exists = (id: number) => (id === 0 ? LOCAL : q.byId.get(id) !== undefined);
/** Есть ли сейчас связь с сервером (локальный считаем доступным всегда — до первого отказа awg-ctrl). */
const isOnline = (id: number) => (id === 0 ? LOCAL : conns.has(id));
/** Все серверы реестра: локальный (если есть) и ноды. */
const ids = (): number[] => [...(LOCAL ? [0] : []), ...(q.all.all() as NodeRow[]).map(n => n.id)];
// Подписчики на «сервер снова на связи» — подписка по этому сигналу догоняет пропущенное.
const onlineHandlers: ((id: number) => void)[] = [];
const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); };
// Подписчики на «сервер удаляют». beforeDelete — проверка: может запретить удаление и не
// меняет ui.db (запретить может и следующий подписчик). forcible — запрет снимается
// подтверждением оператора (force): сервер не всё успел подтвердить, а не используется.
// afterDelete — уборка своих ссылок на сервер, когда он уже удалён.
type DeleteVeto = { error: string; forcible: boolean } | null;
const deleteHandlers: ((id: number, force: boolean) => Promise<DeleteVeto>)[] = [];
const beforeDelete = (cb: (id: number, force: boolean) => Promise<DeleteVeto>) => { deleteHandlers.push(cb); };
const afterHandlers: ((id: number) => void)[] = [];
const afterDelete = (cb: (id: number) => void) => { afterHandlers.push(cb); };
/** Сервер по умолчанию: локальный, а без него — первая нода. */
function defaultId(): number {
if (LOCAL) return 0;
@@ -205,7 +231,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
clearTimeout(authTimer);
if (joining) q.bind.run(pubB64 as string, row.id);
q.seen.run(now(), row.id);
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
const version = typeof m.version === "string" && VERSION_RE.test(m.version) ? m.version : null;
q.seen.run(now(), version, row.id);
const old = conns.get(row.id);
conn = { ws, seq: 0, alive: true, pending: new Map() };
@@ -216,6 +244,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
ws.send(JSON.stringify({ t: "ready" }));
console.log(`nodes: «${row.name}» (#${row.id}) подключена${joining ? " (первое подключение)" : ""}`);
void refreshHealth(row.id);
for (const cb of onlineHandlers) { try { cb(row.id); } catch { /* подписчик сам разберётся */ } }
}
ws.on("message", raw => {
@@ -241,7 +270,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
}
function start() {
if (LOCAL) void refreshHealth(0);
if (LOCAL) {
void refreshHealth(0);
for (const cb of onlineHandlers) { try { cb(0); } catch { /* подписчик сам разберётся */ } }
}
if (!NODE_PORT || !tls) return;
const server = https.createServer(tls);
const wss = new WebSocketServer({ server, path: "/node/v1", maxPayload: 4 * 1024 * 1024 });
@@ -274,20 +306,25 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const router = Router();
router.get("/", async (_req: Request, res: Response) => {
if (LOCAL) await refreshHealth(0);
const nodes: unknown[] = [];
// Серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и для метрик.
const list = () => {
const nodes: { id: number; name: string; local: boolean; online: boolean; pending: boolean; last_seen: number | null; version: string | null; health: Health | null }[] = [];
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, health: h });
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
}
for (const n of q.all.all() as NodeRow[]) {
nodes.push({
id: n.id, name: n.name, local: false, online: conns.has(n.id),
pending: n.pub === null, last_seen: n.last_seen, health: health.get(n.id) ?? null,
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
});
}
res.json({ hub: hubReady(), local: LOCAL, nodes });
return nodes;
};
router.get("/", async (_req: Request, res: Response) => {
if (LOCAL) await refreshHealth(0);
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes: list() });
});
router.post("/", (req: Request, res: Response) => {
@@ -325,21 +362,28 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
res.json({ id, name });
});
router.delete("/:id", (req: Request, res: Response) => {
router.delete("/:id", async (req: Request, res: Response) => {
const id = idOf(req);
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
// Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе.
const used = (["api_keys", "master_keys"] as const).some(t => {
try { return (uidb.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE server_id = ?`).get(id) as { n: number }).n > 0; }
catch { return false; }
});
if (used) { res.status(409).json({ error: "К серверу привязаны ключи API или мастер-ключи — сначала удалите их" }); return; }
// Ключи API и мастер-ключи проверяют их владельцы в своих обработчиках (beforeDelete).
const force = req.query.force === "1";
for (const cb of deleteHandlers) {
let veto: DeleteVeto;
try { veto = await cb(id, force); } catch { veto = { error: "Не удалось подготовить сервер к удалению", forcible: true }; }
if (veto) { res.status(409).json({ error: veto.error, can_force: veto.forcible && !force }); return; }
}
if (!q.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
const c = conns.get(id);
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
q.delete.run(id);
// Хвосты подписки для этого сервера больше некому доделать.
for (const t of ["peer_tombstones", "server_profiles"]) {
try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ }
}
for (const cb of afterHandlers) { try { cb(id); } catch (e) { console.warn(`nodes: уборка после удаления #${id}: ${(e as Error).message}`); } }
res.json({ success: true, id });
});
return { router, ctrlFor, exists, defaultId, start };
return { router, ctrlFor, exists, isOnline, ids, list, onOnline, beforeDelete, afterDelete, defaultId, start };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.3.1",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "vite",
+113 -17
View File
@@ -9,6 +9,8 @@ import axios from "axios";
import Database from "better-sqlite3";
import { createSub } from "./sub";
import { createNodes } from "./nodes";
import { createMetrics } from "./metrics";
import { createMonitor } from "./monitor";
const app = express();
const PORT = Number(process.env.PORT) || 8080;
@@ -73,17 +75,56 @@ uidb.exec(`
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_used INTEGER
)
);
-- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах
-- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users,
-- всегда входит в набор.
CREATE TABLE IF NOT EXISTS api_key_servers (
key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
PRIMARY KEY (key_id, server_id)
);
-- Ключи со времён одного сервера: набор — их server_id.
INSERT OR IGNORE INTO api_key_servers (key_id, server_id)
SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers);
`);
const keyStmts = {
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
byId: uidb.prepare<[number]>("SELECT * FROM api_keys WHERE id = ?"),
setDefault: uidb.prepare<[number, number]>("UPDATE api_keys SET server_id = ? WHERE id = ?"),
serversOf: uidb.prepare<[number]>("SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id"),
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)"),
serversClear: uidb.prepare<[number]>("DELETE FROM api_key_servers WHERE key_id = ?"),
keysOnServer: uidb.prepare<[number]>(
"SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?"),
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
};
const keyServers = (id: number) => (keyStmts.serversOf.all(id) as { server_id: number }[]).map(r => r.server_id);
// Набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в наборе, иначе —
// наименьший из набора.
function setKeyServers(id: number, ids: number[]) {
const row = keyStmts.byId.get(id) as ApiKeyRow;
uidb.transaction(() => {
keyStmts.serversClear.run(id);
for (const sid of ids) keyStmts.serverAdd.run(id, sid);
if (!ids.includes(row.server_id)) keyStmts.setDefault.run(ids[0], id);
})();
}
// Набор серверов из тела запроса: непустой, без повторов, только существующие.
function parseKeyServers(v: unknown): number[] | string {
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
const ids = [...new Set(v)];
if (!ids.every(id => Number.isInteger(id) && nodes.exists(id as number))) return "Такого сервера нет";
return (ids as number[]).sort((a, b) => a - b);
}
function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex");
}
@@ -165,34 +206,51 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
res.json({ ok: true });
});
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
// до выдачи JWT. Наружу уходит только имя продукта и версия.
app.get("/ui/brand", (_req: Request, res: Response) => {
// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting.
// Экран логина обходится без вордмарка.
app.get("/ui/brand", requireAuth, (_req: Request, res: Response) => {
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
});
// Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут.
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
res.json({ keys: keyStmts.list.all() });
res.json({ keys: (keyStmts.list.all() as ApiKeyRow[]).map(k => ({ ...k, servers: keyServers(k.id) })) });
});
// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию.
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
const { label, server_id } = req.body as { label?: string; server_id?: number };
const { label, server_id, servers } = req.body as { label?: string; server_id?: number; servers?: unknown };
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
const sid = server_id === undefined ? nodes.defaultId() : Number(server_id);
if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; }
const ids = parseKeyServers(servers ?? [server_id === undefined ? nodes.defaultId() : Number(server_id)]);
if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
const sid = server_id !== undefined && ids.includes(Number(server_id)) ? Number(server_id) : ids[0];
const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000));
let id = 0;
uidb.transaction(() => {
id = Number(keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000)).lastInsertRowid);
for (const s of ids) keyStmts.serverAdd.run(id, s);
})();
// Открытое значение отдаётся один раз — дальше в БД только хэш.
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
res.status(201).json({ id, label, prefix, key, server_id: sid, servers: ids });
});
// Сменить набор серверов, не перевыпуская ключ.
app.patch("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || !keyStmts.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
const ids = parseKeyServers((req.body as { servers?: unknown } | undefined)?.servers);
if (typeof ids === "string") { res.status(400).json({ error: ids }); return; }
setKeyServers(id, ids);
const row = keyStmts.byId.get(id) as ApiKeyRow;
res.json({ id, server_id: row.server_id, servers: ids });
});
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
keyStmts.delete.run(id);
uidb.transaction(() => { keyStmts.serversClear.run(id); keyStmts.delete.run(id); })();
res.json({ success: true, id });
});
@@ -214,7 +272,7 @@ async function proxy(req: Request, res: Response) {
}
}
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
interface ExtRequest extends Request { apiKey?: ApiKeyRow; servers?: number[]; serverId?: number; }
function requireApiKey(req: Request, res: Response, next: NextFunction) {
const key = (req.headers["x-api-key"] as string) ?? "";
@@ -223,6 +281,18 @@ function requireApiKey(req: Request, res: Response, next: NextFunction) {
if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
(req as ExtRequest).apiKey = row;
(req as ExtRequest).servers = keyServers(row.id);
next();
}
// Сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка — сервер ключа по
// умолчанию. Сервер вне набора — как несуществующий.
function pickKeyServer(req: Request, res: Response, next: NextFunction) {
const r = req as ExtRequest;
const raw = req.headers["x-server-id"];
const id = raw === undefined ? r.apiKey!.server_id : Number(raw);
if (!Number.isInteger(id) || !r.servers!.includes(id)) { res.status(404).json({ error: "Сервер не найден" }); return; }
r.serverId = id;
next();
}
@@ -243,8 +313,9 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
const ext = express.Router();
ext.use(requireApiKey);
// Ключ привязан к server_id — все вызовы уходят на его сервер.
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0);
// vpn://-пользователи живут на одном сервере — на выбранном pickKeyServer.
ext.use("/users", pickKeyServer);
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).serverId ?? 0);
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
@@ -286,14 +357,39 @@ app.use("/api/v1", ext);
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname, version: VERSION });
app.use("/ui/nodes", requireAuth, nodes.router);
// sen://-подписка пока обслуживает один сервер — по умолчанию. Несколько нод — следующий шаг.
const sub = createSub({ uidb, ctrl: (m, p, b) => nodes.ctrlFor(nodes.defaultId())(m, p, b), baseDir: __dirname });
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices);
// Prometheus: /metrics за отдельным токеном «только метрики» (не JWT и не ключ API), токеном
// управляет панель. Регистрируется до SPA-фолбэка внизу, иначе его перехватит `*`.
const metrics = createMetrics({ uidb, version: VERSION, servers: nodes });
app.use("/ui/metrics-token", requireAuth, metrics.router);
app.get("/metrics", metrics.requireToken, metrics.handler);
// Монитор: встроенный Prometheus и графики во вкладке. Ставит его не панель, а awg-ctrl от root.
const monitor = createMonitor({ ctrlLocal: ctrl, localEnabled: (process.env.LOCAL_NODE ?? "on").toLowerCase() !== "off" });
app.use("/ui/monitor", requireAuth, monitor.router);
// Мастер-ключи во внешнем 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(express.static(path.join(__dirname, "dist")));
+555 -245
View File
File diff suppressed because it is too large Load Diff
+230 -63
View File
@@ -3,15 +3,18 @@
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios';
import './tokens.css';
import './App.css';
import {
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId,
TOKEN_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId, serversLabel, openTab, MIN_NODE_VERSION,
type NodeInfo, type Brand,
} from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh, IcoChevronDown } from './components/icons';
import ServerBar from './components/ServerBar';
import DrumTitle from './components/DrumTitle';
import { useTheme, setThemeMode } from './lib/theme';
import AddServerModal from './components/AddServerModal';
import { TABS } from './tabs';
import { TABS, TABS_BY_GROUP } from './tabs';
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
@@ -23,15 +26,20 @@ export default function App() {
const [statusText, setStatusText] = useState('');
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [hubEnabled, setHubEnabled] = useState(false);
const [panelVersion, setPanelVersion] = useState<string | null>(null);
const [serverId, setServerIdState] = useState<number | null>(null);
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [serversOpen, setServersOpen] = useState(false); // выпадающий список серверов
const serverSelectRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState(TABS[0].id);
// Встроенный Prometheus: null — ещё не знаем; fresh — только что поставили (пункт «Монитор» въезжает в меню);
// leaving — только что удалили (пункт ещё в меню, пока доигрывает уход)
const [prom, setProm] = useState<{ ready: boolean; fresh: boolean; leaving: boolean } | null>(null);
const [restarting, setRestarting] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const { theme } = useTheme();
const touchStartX = useRef(0);
const touchCurrentX = useRef(0);
@@ -55,6 +63,7 @@ export default function App() {
const list: NodeInfo[] = data.nodes ?? [];
setNodes(list);
setHubEnabled(!!data.hub);
setPanelVersion(data.version ?? null);
const cur = getServerId();
if (cur !== null && list.some(n => n.id === cur)) return;
let saved: number | null = null;
@@ -102,7 +111,15 @@ export default function App() {
const deleteNode = useCallback(async (n: NodeInfo) => {
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
try {
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
try {
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
} catch (e) {
// Сервер не подтвердил, что снял пиров удалённых мастер-ключей (не в сети) —
// панель объясняет, что останется, и удаляет только с согласия.
const data = axios.isAxiosError(e) ? e.response?.data : undefined;
if (!data?.can_force || !confirm(`${data.error}\n\nВсё равно удалить «${n.name}»?`)) throw e;
await apiFetch('DELETE', `/ui/nodes/${n.id}?force=1`, token);
}
showMsg('Сервер удалён');
await loadNodes(token);
} catch (e) {
@@ -137,6 +154,24 @@ export default function App() {
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
}, []);
// Токен протух (24 ч) или отозван, пока панель открыта: запросы вкладок падали с 401, а экран
// оставался прежним. Выходим только по 401 самой панели («Unauthorized» — её JWT не принят):
// 401 от awg-ctrl ноды (внутренняя авторизация) пересылается как есть и сессию не рвёт.
useEffect(() => {
const id = axios.interceptors.response.use(undefined, e => {
if (axios.isAxiosError(e) && e.response?.status === 401 && e.response.data?.error === 'Unauthorized') {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setNodes([]);
setServerId(null);
setServerIdState(null);
showMsg('Сессия истекла — войдите снова');
}
return Promise.reject(e);
});
return () => axios.interceptors.response.eject(id);
}, [showMsg]);
// Восстановить сессию из сохранённого токена
useEffect(() => {
const saved = localStorage.getItem(TOKEN_KEY);
@@ -155,23 +190,20 @@ export default function App() {
return () => clearInterval(t);
}, [token, loadNodes]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
// Имя продукта и версия — с сервера, только после логина: роут за авторизацией, чтобы
// панель не опознавалась сканером. Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
axios.get('/ui/brand')
if (!token) return;
axios.get('/ui/brand', { headers: { Authorization: `Bearer ${token}` } })
.then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK,
channel: data.channel || '',
version: data.version || '',
}))
.catch(() => { /* остаётся фолбэк */ });
}, []);
}, [token]);
// Применять и сохранять тему
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme);
}, [theme]);
useEffect(() => { document.title = token ? brand.brand : 'Вход'; }, [token, brand.brand]);
// Блокировать прокрутку фона когда drawer открыт
useEffect(() => {
@@ -179,6 +211,72 @@ export default function App() {
return () => { document.body.style.overflow = ''; };
}, [drawerOpen]);
// На широком окне (≥1200px) меню постоянное — модальный drawer там не нужен
useEffect(() => {
const mq = window.matchMedia('(min-width: 1200px)');
const h = () => { if (mq.matches) setDrawerOpen(false); };
mq.addEventListener('change', h);
return () => mq.removeEventListener('change', h);
}, []);
// Список серверов закрывается кликом мимо него и по Escape
useEffect(() => {
if (!serversOpen) return;
const down = (e: MouseEvent) => {
if (!serverSelectRef.current?.contains(e.target as Node)) setServersOpen(false);
};
const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setServersOpen(false); };
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
return () => { document.removeEventListener('mousedown', down); document.removeEventListener('keydown', key); };
}, [serversOpen]);
// Есть ли встроенный Prometheus — от него зависит пункт «Монитор» (requires: 'prometheus').
// Узнаём при входе, раз в минуту (могли поставить из CLI) и сразу от вкладок (announcePrometheus).
// Анимации — только при переходах «не было → есть» и «было → нет», не при каждой загрузке страницы.
const notePrometheus = useCallback((ready: boolean) => {
setProm(p => (p?.ready === ready ? p : { ready, fresh: p !== null && ready, leaving: !!p?.ready && !ready }));
}, []);
// Уход длится --duration-medium2 (300 мс); потом пункт убираем из меню совсем
useEffect(() => {
if (!prom?.leaving) return;
const t = setTimeout(() => setProm(p => (p?.leaving ? { ...p, leaving: false } : p)), 300);
return () => clearTimeout(t);
}, [prom?.leaving]);
useEffect(() => {
if (!token) { setProm(null); return; }
const load = () => apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token)
.then(r => notePrometheus(!!r.ready))
.catch(() => { /* не знаем — меню не трогаем */ });
void load();
const t = setInterval(load, 60000);
const h = (e: Event) => notePrometheus((e as CustomEvent<boolean>).detail);
window.addEventListener('awg:prometheus', h);
return () => { clearInterval(t); window.removeEventListener('awg:prometheus', h); };
}, [token, notePrometheus]);
// tabVisible — вкладка доступна; в меню она ещё и на время ухода
const tabVisible = (t: { requires?: 'prometheus' }) => t.requires !== 'prometheus' || !!prom?.ready;
const tabLeaving = (t: { requires?: 'prometheus' }) => t.requires === 'prometheus' && !!prom?.leaving;
const navGroups = TABS_BY_GROUP
.map(g => ({ ...g, tabs: g.tabs.filter(t => tabVisible(t) || tabLeaving(t)) }))
.filter(g => g.tabs.length > 0);
// Активная вкладка пропала из меню (Prometheus удалили) — на первую
useEffect(() => {
const t = TABS.find(x => x.id === activeTab);
if (t && !tabVisible(t)) setActiveTab(TABS[0].id);
});
// Переход на другую вкладку из самой вкладки (openTab в lib/shared)
useEffect(() => {
const h = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
if (TABS.some(t => t.id === id)) setActiveTab(id);
};
window.addEventListener('awg:open-tab', h);
return () => window.removeEventListener('awg:open-tab', h);
}, []);
// Закрывать drawer по Escape
useEffect(() => {
if (!drawerOpen) return;
@@ -193,33 +291,27 @@ export default function App() {
const chrome = tab.chrome ?? {};
const showServerBar = chrome.serverBar !== false;
const showRestart = chrome.restart !== false;
const current = nodes.find(n => n.id === serverId) ?? null;
// Позиция вкладки в меню (по группам) — направление прокрутки заголовка-барабана
const tabOrder = navGroups.flatMap(g => g.tabs).findIndex(t => t.id === tab.id);
const themeToggle = (
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setThemeMode(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
);
return (
<div className="app">
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && showRestart && serverId !== null && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
title="Перезапустить AWG-интерфейс"
onClick={restartAwg}
disabled={restarting}
>
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
</button>
)}
{!token ? (
<div className="login-screen">
<span className="theme-toggle--fixed">{themeToggle}</span>
<div className="login-card">
<span className="logo-name">{brand.brand}</span>
<span className="logo-name">Вход</span>
<p className="login-sub">Войдите, чтобы продолжить</p>
<input
className="field"
@@ -255,7 +347,8 @@ export default function App() {
>
<IcoMenu />
</button>
<span className="top-bar-title">{tab.label}</span>
<span className="top-bar-title"><DrumTitle text={tab.label} order={tabOrder} /></span>
<span className="top-bar-end">{themeToggle}</span>
</header>
{/* Затемнение под drawer */}
@@ -271,6 +364,15 @@ export default function App() {
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Кнопка меню rail (600–1199px): раскрывает его в модальный drawer */}
<button
className="rail-menu"
aria-label={drawerOpen ? 'Свернуть меню' : 'Развернуть меню'}
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(o => !o)}
>
<IcoMenu />
</button>
<div className="sidebar-logo">
<span className="logo-name">{brand.brand}</span>
<span className="sidebar-version">
@@ -278,40 +380,105 @@ export default function App() {
</span>
</div>
<hr className="drawer-divider" />
{TABS.map(t => (
<div
key={t.id}
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
{navGroups.map(g => (
<nav key={g.id} className="nav-group" aria-label={g.label}>
<span className="nav-group-title">{g.label}</span>
{g.tabs.map(t => (
<button
key={t.id}
type="button"
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}${tabLeaving(t) ? ' nav-item--leave' : t.requires === 'prometheus' && prom?.fresh ? ' nav-item--enter' : ''}`}
aria-current={t.id === activeTab ? 'page' : undefined}
aria-hidden={tabLeaving(t) || undefined}
tabIndex={tabLeaving(t) ? -1 : undefined}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<span className="nav-icon"><t.Icon /></span>
<span className="nav-label">{t.label}</span>
</button>
))}
</nav>
))}
<div className="sidebar-spacer" />
<button className="btn btn--danger" onClick={logout}>
<IcoLogout /> Выйти
<button className="btn btn--danger sidebar-logout" aria-label="Выйти" onClick={logout}>
<IcoLogout /> <span className="nav-label">Выйти</span>
</button>
</aside>
<main className="main">
<h2 className="page-title">{tab.label}</h2>
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
{showServerBar && (
<ServerBar
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
onSelect={pickServer}
onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })}
onDelete={deleteNode}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
/>
)}
{/* Шапка страницы: заголовок слева; справа — выбор сервера, перезапуск AWG и тема.
На телефоне заголовок и тема — в верхнем баре, выбор сервера — во всю ширину. */}
<div className="page-head">
<h2 className="page-title"><DrumTitle text={tab.label} order={tabOrder} /></h2>
<div className="page-actions">
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают.
Текущий сервер — кнопкой, остальные — выпадающим списком под ней. */}
{showServerBar && (
<div className="server-select" ref={serverSelectRef}>
<button
type="button"
className={`server-current${serversOpen ? ' server-current--open' : ''}`}
aria-expanded={serversOpen}
aria-controls="server-menu"
onClick={() => setServersOpen(o => !o)}
>
<span
className={`status-dot${current?.online ? ' status-dot--online' : ''}`}
role="img"
aria-label={current?.online ? 'на связи' : 'нет связи'}
/>
<span className="server-current-info">
<span className="server-card-name">
{current ? (current.health?.server || current.name) : 'Сервер не выбран'}
</span>
<span className="server-card-ip">
{!current ? 'добавьте ноду'
: current.pending ? 'ждёт подключения'
: current.outdated ? `нужно обновить до ${MIN_NODE_VERSION}`
: !current.online ? 'нет связи'
: current.health?.ip}
</span>
</span>
<span className="server-current-count">{serversLabel(nodes.length)}</span>
<span className="server-current-caret"><IcoChevronDown /></span>
</button>
{serversOpen && (
<div className="server-menu" id="server-menu" aria-label="Серверы">
<ServerBar
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
panelVersion={panelVersion}
onSelect={id => { pickServer(id); setServersOpen(false); }}
onAdd={() => { setServersOpen(false); setModal({}); }}
onJoin={n => { setServersOpen(false); setModal({ node: n }); }}
onDelete={deleteNode}
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
onSettings={n => { setServersOpen(false); pickServer(n.id); openTab('settings', 'server'); }}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
/>
</div>
)}
</div>
)}
{showRestart && serverId !== null && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
title="Перезапустить AWG-интерфейс"
onClick={restartAwg}
disabled={restarting}
>
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
</button>
)}
<span className="page-theme">{themeToggle}</span>
</div>
</div>
{serverId === null && showServerBar ? (
<p className="login-sub">
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
+1 -1
View File
@@ -85,6 +85,7 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
<button className="btn btn--text" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
{result ? (
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
@@ -94,7 +95,6 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs
Создать
</button>
)}
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
</div>
</div>
</div>
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState } from 'react';
// Заголовок-«барабан»: при смене текста старый уходит гранью барабана, новый выкатывается на его
// место. order — позиция вкладки в меню: дальше по списку — барабан крутится вверх, обратно — вниз.
// Первый показ — без анимации. Стили — App.css, «Заголовок-барабан».
export default function DrumTitle({ text, order }: { text: string; order: number }) {
const [s, setS] = useState({ text, order, from: null as string | null, up: true, n: 0 });
// Текст сменился — запоминаем прежний прямо в рендере, чтобы новый не мелькнул без анимации
if (s.text !== text) setS({ text, order, from: s.text, up: order >= s.order, n: s.n + 1 });
return (
<span key={s.n} className={`drum${s.from !== null ? ` drum--roll drum--${s.up ? 'up' : 'down'}` : ''}`}>
{s.from !== null && <span className="drum-out" aria-hidden="true">{s.from}</span>}
<span className="drum-in">{text}</span>
</span>
);
}
+194
View File
@@ -0,0 +1,194 @@
// 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, useMemo, useRef, useState } from 'react';
// Линейный график для вкладки «Монитор». Одна ось, линия 2 px, конечная точка с кольцом цвета поверхности,
// перекрестие с подсказкой по всем рядам, легенда при ≥ 2 рядах, табличный вид и управление стрелками.
// Цвета рядов — токены темы (--series-N в monitor.css), подписи и значения — всегда цветом текста.
export interface ChartSeries { id: string; name: string; color: string; points: [number, number][] }
const M = { left: 46, right: 14, top: 10, bottom: 24 };
// Шкала оси: круглый шаг (1, 2, 2.5, 5 × 10ⁿ) и 3–5 интервалов, чтобы подписи были 0 / 50 / 100 / 150, а не 0 / 63 / 125.
function niceScale(max: number): { top: number; ticks: number[] } {
if (!(max > 0)) return { top: 1, ticks: [0, 1] };
const raw = max / 4;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
const step = ([1, 2, 2.5, 5, 10].map(f => f * p).find(s => s >= raw)) ?? 10 * p;
const n = Math.max(1, Math.ceil(max / step - 1e-9));
return { top: step * n, ticks: Array.from({ length: n + 1 }, (_, i) => step * i) };
}
const timeLabel = (t: number, span: number) => {
const d = new Date(t * 1000);
const hm = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
return span > 86400 * 1.5 ? `${d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} ${hm}` : hm;
};
export default function LineChart({ title, series, from, to, step, format, formatAxis, max, dim = false }: {
title: string;
series: ChartSeries[];
from: number;
to: number;
step: number; // шаг данных, с: разрыв длиннее трёх шагов рвёт линию, а не рисует мост
format: (v: number) => string; // значение в подсказке и таблице
formatAxis?: (v: number) => string; // компактная подпись оси
max?: number; // фиксированный верх оси (проценты — 100); без него подбирается по данным
dim?: boolean; // идёт перезагрузка: держим прежний кадр бледнее
}) {
const wrap = useRef<HTMLDivElement>(null); // содержимое карточки без её padding: от него зависит ширина svg
const [w, setW] = useState(640);
const [hover, setHover] = useState<number | null>(null); // индекс по оси времени
const [table, setTable] = useState(false);
const H = 200;
useEffect(() => {
const el = wrap.current;
if (!el) return;
const ro = new ResizeObserver(() => setW(Math.max(260, el.clientWidth)));
ro.observe(el); setW(Math.max(260, el.clientWidth));
return () => ro.disconnect();
}, []);
// Общая ось времени: метки первого непустого ряда (Prometheus выравнивает точки по шагу).
const times = useMemo(() => {
const set = new Set<number>();
for (const s of series) for (const p of s.points) set.add(p[0]);
return [...set].sort((a, b) => a - b);
}, [series]);
const byTime = useMemo(() => series.map(s => new Map(s.points)), [series]);
const empty = times.length === 0;
const { top: yMax, ticks: yTicks } = max !== undefined
? { top: max, ticks: [0, 1, 2, 3, 4].map(i => (max / 4) * i) }
: niceScale(Math.max(0, ...series.flatMap(s => s.points.map(p => p[1]))) * 1.04);
const x = (t: number) => M.left + ((t - from) / Math.max(1, to - from)) * (w - M.left - M.right);
const y = (v: number) => H - M.bottom - (v / yMax) * (H - M.top - M.bottom);
const path = (pts: [number, number][]) => {
let d = '', prev: number | null = null;
for (const [t, v] of pts) {
d += `${prev !== null && t - prev <= step * 3 ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`;
prev = t;
}
return d;
};
const span = to - from;
const xTicks = [0, 1, 2, 3, 4].map(i => from + (span / 4) * i);
const axis = formatAxis ?? format;
const onMove = (e: React.PointerEvent<SVGRectElement>) => {
if (!times.length) return;
const r = e.currentTarget.getBoundingClientRect();
const t = from + ((e.clientX - r.left) / Math.max(1, r.width)) * span;
let best = 0;
for (let i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
setHover(best);
};
const onKey = (e: React.KeyboardEvent) => {
if (!times.length) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); setHover(h => Math.max(0, (h ?? times.length) - 1)); }
if (e.key === 'ArrowRight') { e.preventDefault(); setHover(h => Math.min(times.length - 1, (h ?? -1) + 1)); }
if (e.key === 'Escape') setHover(null);
};
const ht = hover !== null ? times[hover] : null;
// Подсказка не вылезает за правый край: правее середины открывается влево от перекрестия
const flip = ht !== null && x(ht) > w * 0.55;
// Табличный вид — те же числа без наведения: ~12 равномерно выбранных строк, последняя — самая свежая
const rows = useMemo(() => {
if (!times.length) return [] as number[];
const n = Math.min(12, times.length);
return Array.from({ length: n }, (_, i) => times[Math.round(((times.length - 1) * (i + 1)) / n)]).reverse();
}, [times]);
return (
<figure className={`mon-chart${dim ? ' mon-chart--dim' : ''}`}>
<figcaption className="mon-chart-head">
<span className="mon-chart-title">{title}</span>
{series.length >= 2 && (
<span className="mon-legend">
{series.map(s => (
<span className="mon-legend-item" key={s.id}>
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />{s.name}
</span>
))}
</span>
)}
<button type="button" className="mon-chart-toggle" aria-pressed={table} onClick={() => setTable(t => !t)}>
{table ? 'График' : 'Таблица'}
</button>
</figcaption>
<div ref={wrap}>
{table ? (
<div className="mon-table-wrap">
<table className="mon-table">
<thead><tr><th>Время</th>{series.map(s => <th key={s.id}>{s.name}</th>)}</tr></thead>
<tbody>
{rows.length === 0 ? <tr><td colSpan={series.length + 1}>Нет данных</td></tr> : rows.map(t => (
<tr key={t}>
<td>{timeLabel(t, span)}</td>
{series.map((s, i) => <td key={s.id}>{byTime[i].has(t) ? format(byTime[i].get(t)!) : '—'}</td>)}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="mon-plot">
<svg width={w} height={H} role="img" tabIndex={0} aria-label={`${title}. Стрелки влево и вправо — значения по времени.`}
onKeyDown={onKey} onBlur={() => setHover(null)}>
{yTicks.map((v, i) => (
<g key={i}>
<line className="mon-grid" x1={M.left} x2={w - M.right} y1={y(v)} y2={y(v)} />
<text className="mon-tick" x={M.left - 8} y={y(v) + 4} textAnchor="end">{axis(v)}</text>
</g>
))}
{xTicks.map((t, i) => (
<text key={i} className="mon-tick" x={x(t)} y={H - 6} textAnchor={i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}>
{timeLabel(t, span)}
</text>
))}
{!empty && series.map(s => (
<path key={s.id} d={path(s.points)} fill="none" style={{ stroke: s.color }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
))}
{/* конечная точка: r 4 + кольцо цвета поверхности, чтобы читалась поверх линий */}
{!empty && series.map(s => s.points.length > 0 && (
<circle key={s.id} cx={x(s.points[s.points.length - 1][0])} cy={y(s.points[s.points.length - 1][1])}
r={4} style={{ fill: s.color }} className="mon-end" />
))}
{ht !== null && (
<g>
<line className="mon-cross" x1={x(ht)} x2={x(ht)} y1={M.top} y2={H - M.bottom} />
{series.map((s, i) => byTime[i].has(ht) && (
<circle key={s.id} cx={x(ht)} cy={y(byTime[i].get(ht)!)} r={4} style={{ fill: s.color }} className="mon-end" />
))}
</g>
)}
{/* зона наведения шире линии: указатель ищет момент времени, а не 2-пиксельную линию */}
<rect x={M.left} y={M.top} width={Math.max(0, w - M.left - M.right)} height={H - M.top - M.bottom}
fill="transparent" onPointerMove={onMove} onPointerLeave={() => setHover(null)} />
</svg>
{empty && <p className="mon-empty">Данные появятся через минуту-две после запуска Prometheus</p>}
{ht !== null && (
<div className="mon-tip" style={{ left: flip ? undefined : x(ht) + 12, right: flip ? w - x(ht) + 12 : undefined, top: M.top }} role="status">
<span className="mon-tip-time">{timeLabel(ht, span)}</span>
{series.map((s, i) => (
<span className="mon-tip-row" key={s.id}>
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />
<b>{byTime[i].has(ht) ? format(byTime[i].get(ht)!) : '—'}</b>
<span>{s.name}</span>
</span>
))}
</div>
)}
</div>
)}
</div>
</figure>
);
}
+28
View File
@@ -0,0 +1,28 @@
// 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 { Priority } from '../lib/shared';
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
// деления канала) чип не показываем: «обычный» там было бы выдумкой. С меткой «торренты» устройство
// фоновое независимо от загрузки канала — подсказка говорит об этом.
export default function PriorityChip({ priority, torrent }: { priority?: Priority; torrent?: boolean }) {
if (!priority) return null;
const bg = priority === 'background';
return (
<span
className="tip-wrap"
data-tip={!bg
? 'Обычный приоритет: канал делится поровну'
: torrent
? 'Пытается качать торренты — пока висит метка, устройство уступает остальным'
: 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'}
>
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
</span>
</span>
);
}
+56 -16
View File
@@ -1,23 +1,50 @@
// 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';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
import { type NodeInfo, genLabel, cmpVersion, MIN_NODE_VERSION } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
// Версия Forgetting на сервере, три состояния: совпадает с панелью — нейтральный чип; нода отстаёт
// от панели (но не старше MIN_NODE_VERSION) или новее неё — жёлтый, стоит обновить; старше
// MIN_NODE_VERSION или не сообщает версию (агент старше 0.3.4) — красный: хаб такую ноду не подключает.
function versionChip(n: NodeInfo, panel: string | null) {
if (n.pending) return null;
let tip = '', level: '' | 'warn' | 'bad' = '';
if (!n.version) {
level = 'bad';
tip = `Версия неизвестна: нода старше 0.3.4, панель с ней не работает — обнови её до ${MIN_NODE_VERSION} (install.sh → «Обновить»)`;
} else if (!n.local && cmpVersion(n.version, MIN_NODE_VERSION) < 0) {
level = 'bad';
tip = `Слишком старая версия: панель с ней не работает, нужна не ниже ${MIN_NODE_VERSION} — обнови ноду (install.sh → «Обновить»)`;
} else if (panel && !n.local) {
const d = cmpVersion(n.version, panel);
if (d < 0) { level = 'warn'; tip = `Устарела: панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
else if (d > 0) { level = 'warn'; tip = `Нода новее панели (${panel}) — обнови панель`; }
}
const chip = (
<span className={`chip server-ver-chip${level ? ` server-ver-chip--${level}` : ''}`}>
{n.version ? `v${n.version}` : 'v?.?.?'}
</span>
);
return tip ? <span className="tip-wrap server-gen-wrap" data-tip={tip}>{chip}</span> : chip;
}
export default function ServerBar({
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete, onSettings,
onRestartAwg, restarting, showRestart = true,
}: {
nodes: NodeInfo[];
panelVersion: string | null;
serverId: number | null;
hubEnabled: boolean;
onSelect: (id: number) => void;
onAdd: () => void;
onJoin: (n: NodeInfo) => void;
onDelete: (n: NodeInfo) => void;
onSettings: (n: NodeInfo) => void;
onRestartAwg: () => void;
restarting: boolean;
showRestart?: boolean;
@@ -37,24 +64,26 @@ export default function ServerBar({
onClick={() => onSelect(n.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
>
{/* toggle — виден на мобильном вместо чипа */}
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
<div className="server-toggle-thumb" />
</div>
{/* чип онлайн/офлайн — виден на десктопе */}
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
<span className="chip-dot" />
</span>
<span
className={`status-dot${n.online ? ' status-dot--online' : ''}`}
role="img"
aria-label={n.online ? 'на связи' : 'нет связи'}
/>
<div className="server-card-info">
<span className="server-card-name">{h?.server || n.name}</span>
{n.pending ? (
<span className="server-card-ip">ждёт подключения</span>
) : n.outdated ? (
<span className="server-card-ip server-card-ip--bad">нужно обновить до {MIN_NODE_VERSION}</span>
) : !n.online ? (
<span className="server-card-ip">нет связи</span>
) : h && (
<>
<span className="server-card-ip">{h.ip}</span>
{(h.module || h.tools) ? (
<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 || '?'}`}
@@ -63,8 +92,9 @@ export default function ServerBar({
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
)}
</>
))}
{versionChip(n, panelVersion)}
</span>
)}
</div>
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
@@ -77,6 +107,16 @@ export default function ServerBar({
<IcoCopy />
</button>
)}
{/* Настройки сервера: у ноды, которая ещё не подключалась, настраивать нечего */}
{!n.pending && n.online && (
<button
className="btn-icon btn-icon--neutral"
aria-label={`Настройки сервера ${n.name}`}
onClick={e => { e.stopPropagation(); onSettings(n); }}
>
<IcoSettings />
</button>
)}
{!n.local && (
<button
className="btn-icon btn-icon--danger"
+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}`;
};
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef } from 'react';
import { type NodeInfo, genLabel, serversLabel, MIN_NODE_VERSION } from '../lib/shared';
import { IcoChevronDown } from './icons';
// Выбор одного сервера внутри страницы: чьи графики показывать в «Мониторе».
// Выглядит как выбор сервера в шапке страницы: кнопка с текущим сервером (точка связи, имя, IP,
// «N серверов», стрелка) и под ней меню-список; выбранная строка залита primary-container. Выбор
// локальный: сервер в шапке панели он не меняет. Серверы без связи видны, но недоступны (на них
// ничего не поставить) — кроме pickOffline: история на графиках у них есть. all — первой строкой
// «Все серверы» (ALL_SERVERS).
// Меню раскрывается от правого края кнопки, как в шапке.
export const ALL_SERVERS = -1;
export default function ServerSelect({ nodes, value, onChange, name, all, pickOffline }: {
nodes: NodeInfo[];
value: number;
onChange: (id: number) => void;
name: string; // id меню (aria-controls)
all?: boolean;
pickOffline?: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Закрывается кликом мимо и по Escape — как меню серверов в шапке
useEffect(() => {
if (!open) return;
const down = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
return () => { document.removeEventListener('mousedown', down); document.removeEventListener('keydown', key); };
}, [open]);
const online = nodes.filter(n => n.online).length;
const current = nodes.find(n => n.id === value) ?? null;
const isAll = value === ALL_SERVERS;
const pick = (id: number) => { setOpen(false); if (id !== value) onChange(id); };
const sub = (n: NodeInfo) => n.pending ? 'ждёт подключения' : n.outdated ? `нужно обновить до ${MIN_NODE_VERSION}` : !n.online ? 'нет связи' : n.health?.ip ?? '';
return (
<div className="server-select" ref={ref}>
<button type="button" className={`server-current${open ? ' server-current--open' : ''}`}
aria-expanded={open} aria-controls={name} onClick={() => setOpen(o => !o)}>
<span className={`status-dot${(isAll ? online > 0 : current?.online) ? ' status-dot--online' : ''}`}
role="img" aria-label={(isAll ? online > 0 : current?.online) ? 'на связи' : 'нет связи'} />
<span className="server-current-info">
<span className="server-card-name">
{isAll ? 'Все серверы' : current ? (current.health?.server || current.name) : 'Сервер не выбран'}
</span>
<span className="server-card-ip">{isAll ? `на связи ${online} из ${nodes.length}` : current ? sub(current) : ''}</span>
</span>
<span className="server-current-count">{serversLabel(nodes.length)}</span>
<span className="server-current-caret"><IcoChevronDown /></span>
</button>
{open && (
<div className="server-menu" id={name} role="listbox" aria-label="Серверы">
{all && (
<button type="button" role="option" aria-selected={isAll}
className={`server-option${isAll ? ' server-option--active' : ''}`} onClick={() => pick(ALL_SERVERS)}>
<span className="status-dot server-option-nodot" aria-hidden="true" />{/* место точки — текст в один столбец */}
<span className="server-current-info">
<span className="server-card-name">Все серверы</span>
<span className="server-option-sub">{serversLabel(nodes.length)} · на связи {online}</span>
</span>
</button>
)}
{nodes.map(n => {
const off = !n.online && !pickOffline;
return (
<button key={n.id} type="button" role="option" aria-selected={n.id === value} disabled={off}
className={`server-option${n.id === value ? ' server-option--active' : ''}`} onClick={() => pick(n.id)}>
<span className={`status-dot${n.online ? ' status-dot--online' : ''}`} role="img"
aria-label={n.online ? 'на связи' : 'нет связи'} />
<span className="server-current-info">
<span className="server-card-name">{n.health?.server || n.name}</span>
<span className="server-card-ip">{sub(n)}</span>
</span>
{n.health?.gen && <span className="server-option-sub">{genLabel(n.health.gen)}</span>}
</button>
);
})}
</div>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
// 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.
// Пометка «торренты»: устройство за последние 10 минут пыталось открыть торрент-соединение, и оно
// было заблокировано. Без флага (блокировка выключена или awg-ctrl старше) чип не показываем.
export default function TorrentChip({ torrent }: { torrent?: boolean }) {
if (!torrent) return null;
return (
<span className="tip-wrap" data-tip="За последние 10 минут пыталось качать торренты: соединения заблокированы, а при делении канала устройство в фоновом приоритете">
<span className="chip chip--error"><span className="chip-dot" />торренты</span>
</span>
);
}
+53
View File
@@ -11,6 +11,11 @@ export const IcoMenu = () => (
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
);
export const IcoChevronDown = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);
export const IcoUsers = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
@@ -90,7 +95,55 @@ export const IcoMoon = () => (
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
export const IcoShield = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3z"/><path d="m9 12 2 2 4-4"/>
</svg>
);
export const IcoPulse = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>
</svg>
);
export const IcoMove = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="8" height="16" rx="2"/><rect x="14" y="4" width="8" height="16" rx="2"/><path d="M7 12h10"/><path d="m14 9 3 3-3 3"/>
</svg>
);
export const IcoGear = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
export const IcoCheck = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5 10 17.5 19 7"/>
</svg>
);
// «Ещё» (MD3 more_vert): меню действий строки
export const IcoMore = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/>
</svg>
);
export const IcoHistory = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>
</svg>
);
export const ICONS: Record<string, () => JSX.Element> = {
settings: IcoGear,
users: IcoUsers,
key: IcoKey,
shield: IcoShield,
pulse: IcoPulse,
move: IcoMove,
history: IcoHistory,
};
+66
View File
@@ -0,0 +1,66 @@
// 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 QRCode, { type QRCodeErrorCorrectionLevel } from 'qrcode';
// Фирменный QR: скруглённые модули (соседи сливаются в «капли», одиночный — круг) и угловые
// «глаза» с фиолетовой серединкой. Рисуется в SVG по матрице библиотеки, отдаётся data-URL для <img>.
//
// Цвета — светлой темы и не зависят от текущей: сканеры читают только тёмный код на светлом фоне,
// а токены тёмной темы светлые. Логотипа нет: у vpn:// (уровень 'L') нет запаса коррекции ошибок.
const DARK = '#3a2e22'; // on-surface светлой темы
const ACCENT = '#6a45b8'; // primary светлой темы
const LIGHT = '#ffffff';
const MARGIN = 2; // тихая зона в модулях — как было у toDataURL
const R = 0.5; // радиус открытого угла модуля (0.5 — одиночный модуль становится кругом)
// Квадрат 1×1 в (x, y) со скруглением только тех углов, у которых оба соседа по сторонам пусты.
function cell(x: number, y: number, tl: boolean, tr: boolean, br: boolean, bl: boolean): string {
const a = tl ? R : 0, b = tr ? R : 0, c = br ? R : 0, d = bl ? R : 0;
return `M${x + a} ${y}H${x + 1 - b}` + (b ? `a${b} ${b} 0 0 1 ${b} ${b}` : '')
+ `V${y + 1 - c}` + (c ? `a${c} ${c} 0 0 1 ${-c} ${c}` : '')
+ `H${x + d}` + (d ? `a${d} ${d} 0 0 1 ${-d} ${-d}` : '')
+ `V${y + a}` + (a ? `a${a} ${a} 0 0 1 ${a} ${-a}` : '') + 'Z';
}
// Скруглённый прямоугольник как подпуть (для «глаз»).
function rrect(x: number, y: number, w: number, h: number, r: number): string {
return `M${x + r} ${y}H${x + w - r}a${r} ${r} 0 0 1 ${r} ${r}V${y + h - r}a${r} ${r} 0 0 1 ${-r} ${r}`
+ `H${x + r}a${r} ${r} 0 0 1 ${-r} ${-r}V${y + r}a${r} ${r} 0 0 1 ${r} ${-r}Z`;
}
// Бросает, если текст не помещается в QR на этом уровне коррекции.
export function qrDataUrl(text: string, errorCorrectionLevel: QRCodeErrorCorrectionLevel): string {
const { modules } = QRCode.create(text, { errorCorrectionLevel });
const n = modules.size;
const finder = (x: number, y: number) =>
(x < 7 && y < 7) || (x >= n - 7 && y < 7) || (x < 7 && y >= n - 7);
const on = (x: number, y: number) =>
x >= 0 && y >= 0 && x < n && y < n && !finder(x, y) && !!modules.get(y, x);
// Все модули — одним путём: соседние квадраты сливаются без щелей сглаживания.
let body = '';
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (!on(x, y)) continue;
const up = on(x, y - 1), down = on(x, y + 1), left = on(x - 1, y), right = on(x + 1, y);
body += cell(x, y, !up && !left, !up && !right, !down && !right, !down && !left);
}
}
// «Глаза»: кольцо 7×7 (внешний минус внутренний контур, evenodd) и серединка 3×3.
let rings = '', pupils = '';
for (const [x, y] of [[0, 0], [n - 7, 0], [0, n - 7]]) {
rings += rrect(x, y, 7, 7, 2) + rrect(x + 1, y + 1, 5, 5, 1.5);
pupils += rrect(x + 2, y + 2, 3, 3, 1);
}
const s = n + MARGIN * 2;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${-MARGIN} ${-MARGIN} ${s} ${s}" width="560" height="560">`
+ `<rect x="${-MARGIN}" y="${-MARGIN}" width="${s}" height="${s}" fill="${LIGHT}"/>`
+ `<path d="${body}" fill="${DARK}"/>`
+ `<path d="${rings}" fill="${DARK}" fill-rule="evenodd"/>`
+ `<path d="${pupils}" fill="${ACCENT}"/>`
+ '</svg>';
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
+65 -1
View File
@@ -5,6 +5,9 @@
import axios from 'axios';
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
export type AwgGen = '2' | '3.1';
// Текущая приоритетность ключа/устройства: «background» — канал занят, а он давно качает больше своей доли.
export type Priority = 'normal' | 'background';
export interface User {
name: string;
ip: string;
@@ -15,6 +18,8 @@ export interface User {
lastHandshake: number;
rx?: number;
tx?: number;
priority?: Priority; // нет у awg-ctrl старше деления канала
torrent?: boolean; // пытался качать торренты за последние 10 минут (при включённой блокировке)
}
export interface ServerInfo {
name: string;
@@ -32,7 +37,8 @@ export interface ApiKey {
id: number;
label: string;
prefix: string;
server_id: number;
server_id: number; // сервер по умолчанию для /api/v1/users
servers: number[]; // какие серверы видит ключ
created_at: number;
last_used: number | null;
}
@@ -50,6 +56,19 @@ export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export const SERVER_KEY = 'awg_server';
export const SETTINGS_SECTION_KEY = 'awg_settings_section'; // открытый раздел «Настроек»
// Встроенный Prometheus установлен или удалён: вкладка «Монитор» в меню есть только при нём.
// Кто узнал состояние (раздел установки, сам «Монитор») — сообщает App, тот прячет или показывает пункт.
export const announcePrometheus = (ready: boolean) =>
window.dispatchEvent(new CustomEvent('awg:prometheus', { detail: ready }));
// Открыть другую вкладку панели (App слушает событие). section — раздел «Настроек»: его вкладка
// читает из SETTINGS_SECTION_KEY при монтировании, поэтому пишем до перехода.
export function openTab(id: string, section?: string) {
if (section) try { localStorage.setItem(SETTINGS_SECTION_KEY, section); } catch { /* не критично */ }
window.dispatchEvent(new CustomEvent('awg:open-tab', { detail: id }));
}
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
export interface NodeInfo {
@@ -59,9 +78,54 @@ export interface NodeInfo {
online: boolean;
pending: boolean; // нода создана, но ещё ни разу не подключалась
last_seen: number | null;
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
outdated?: boolean; // старше MIN_NODE_VERSION: панель её не подключает, пока не обновят
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
}
// Деление канала (GET/PUT /api/shaping на awg-ctrl сервера).
export interface ShapingStatus {
settings: { enabled: boolean; downMbit: number; upMbit: number; auto: boolean };
active: boolean; // CAKE реально стоит на интерфейсе
congested: boolean;
heavy: string[]; // IP устройств, переведённых в фоновый класс
devices: number; // активных устройств в окне
rateDown: number; // Мбит/с за последний тик
rateUp: number;
}
// Блокировка BitTorrent (GET/PUT /api/torrent на awg-ctrl сервера).
export interface TorrentStatus {
settings: { enabled: boolean };
active: boolean; // nft-таблица реально стоит
tcp: boolean; // проверяется и TCP-рукопожатие (нужен свежий nftables)
hits: number; // заблокированных попыток с последнего применения
seen: { ip: string; name: string }[]; // кто пытался за последние 10 минут
error?: string;
}
// «1 сервер», «3 сервера», «5 серверов»
export function serversLabel(n: number): string {
const m10 = n % 10, m100 = n % 100;
const word = m10 === 1 && m100 !== 11 ? 'сервер'
: m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'сервера' : 'серверов';
return `${n} ${word}`;
}
// Минимальная версия Forgetting на ноде (в хабе — MinNodeVersion, internal/ui/nodes.go): старше — хаб
// ноду не подключает, чип версии красный; между ней и версией панели — жёлтый (просто стоит обновить).
export const MIN_NODE_VERSION = '0.5.0';
// Сравнение версий вида 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 у вкладки, поэтому та
// перечитывает данные уже с новым сервером.
+47
View File
@@ -0,0 +1,47 @@
// 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.
// Тема панели: светлая, тёмная или как в системе. Одно хранилище на всё приложение —
// переключатель в шапке и вкладка «Настройки» читают и меняют его через useTheme().
import { useSyncExternalStore } from 'react';
import { THEME_KEY } from './shared';
export type ThemeMode = 'light' | 'dark' | 'system';
export type Theme = 'light' | 'dark';
const dark = window.matchMedia('(prefers-color-scheme: dark)');
const listeners = new Set<() => void>();
function readMode(): ThemeMode {
try {
const v = localStorage.getItem(THEME_KEY);
if (v === 'dark' || v === 'system') return v;
} catch { /* не критично */ }
return 'light'; // без сохранённого выбора — светлая, как было до режима «как в системе»
}
let mode = readMode();
const resolved = (): Theme => (mode === 'system' ? (dark.matches ? 'dark' : 'light') : mode);
function apply() {
document.documentElement.setAttribute('data-theme', resolved());
listeners.forEach(l => l());
}
// «Как в системе» следует за сменой темы ОС на лету
dark.addEventListener('change', () => { if (mode === 'system') apply(); });
apply();
export function setThemeMode(next: ThemeMode) {
mode = next;
try { localStorage.setItem(THEME_KEY, next); } catch { /* не критично */ }
apply();
}
const subscribe = (l: () => void) => { listeners.add(l); return () => { listeners.delete(l); }; };
// mode — выбор пользователя, theme — что реально применено.
export function useTheme(): { mode: ThemeMode; theme: Theme } {
const snap = useSyncExternalStore(subscribe, () => `${mode}:${resolved()}`);
const [m, t] = snap.split(':') as [ThemeMode, Theme];
return { mode: m, theme: t };
}
+9
View File
@@ -3,8 +3,17 @@
// LICENSE file in the root directory of this source tree.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import axios from 'axios';
import App from './App';
// Пути к API в коде пишутся от корня («/ui/nodes»), а уходят от адреса страницы («ui/nodes»):
// так панель за reverse-proxy в подпапке (example.com/forgetting/) ходит в свой префикс, а в корне
// всё как раньше. Одно место на все запросы, включая будущие.
axios.interceptors.request.use(c => {
if (c.url?.startsWith('/') && !c.url.startsWith('//')) c.url = c.url.slice(1);
return c;
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
-77
View File
@@ -1,77 +0,0 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
/* Открытый API-ключ (показывается один раз при создании) */
.apikey-value {
font-family: var(--mono);
font-size: 13px;
color: var(--on-surface);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: 12px;
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: 12px; color: var(--neutral-text); }
.apikey-empty {
font-size: 14px;
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
}
.apikey-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.apikey-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.apikey-card-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label {
font-size: 16px;
font-weight: 500;
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apikey-card-prefix {
font-family: var(--mono);
font-size: 13px;
color: var(--neutral-text);
word-break: break-all;
}
.apikey-card-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: var(--neutral-text);
}
@media (max-width: 640px) {
.apikey-cards-list { display: flex; }
}
-148
View File
@@ -1,148 +0,0 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
// серверов — ключ будет привязываться к выбранному в сервер-баре.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []);
} catch {
showMsg('Ошибка загрузки ключей');
}
}, [token, showMsg]);
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setCreated({ label: r.label, key: r.key });
await load();
}, [label, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
showMsg('Ключ удалён: ' + lbl);
await load();
}, [token, load, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
return (
<>
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
<div className="toolbar">
<input
className="field"
placeholder="Метка ключа (напр. ci-bot)"
value={label}
onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createKey()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createKey}>
<IcoPlus /> Создать ключ
</button>
</div>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<tr key={k.id}>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
<div className="apikey-cards-list">
<p className="apikey-count">{keys.length} ключей</p>
{keys.length === 0 ? (
<p className="apikey-empty">Нет ключей</p>
) : keys.map((k, i) => (
<div className="apikey-card" key={k.id}>
<div className="apikey-card-header">
<div className="apikey-card-title">
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</div>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
</div>
</div>
))}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">Ключ «{created.label}» создан</p>
<p className="qr-hint">
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
-6
View File
@@ -1,6 +0,0 @@
{
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"order": 2
}
+36
View File
@@ -0,0 +1,36 @@
/* Вкладка «Подключения»: лента событий и список «кто когда был». Строки — гибкие, без таблицы:
на телефоне колонки сами переносятся. */
.cl { display: flex; flex-direction: column; gap: 12px; }
.cl-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.cl-search { flex: 1; min-width: 200px; width: auto; }
.cl-hint { margin: 0; font-size: 13px; color: var(--neutral-text); }
.cl-err { text-align: left; }
.cl-list {
list-style: none; margin: 0; padding: 0;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--shape-large);
overflow: hidden;
}
.cl-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px;
padding: 12px 16px;
border-top: 1px solid var(--outline-variant);
}
.cl-row:first-child { border-top: none; }
.cl-time { flex: 0 0 110px; font-size: 13px; color: var(--neutral-text); font-variant-numeric: tabular-nums; }
.cl-chip { flex: none; min-width: 112px; }
.cl-who { flex: 1 1 200px; display: flex; flex-direction: column; min-width: 0; }
.cl-who b { font-weight: 500; overflow-wrap: anywhere; }
.cl-sub { font-size: 12px; color: var(--neutral-text); overflow-wrap: anywhere; }
.cl-session, .cl-seen { font-size: 13px; color: var(--on-surface); font-variant-numeric: tabular-nums; }
.cl-empty { padding: 24px 16px; text-align: center; color: var(--neutral-text); }
.cl-more { display: flex; justify-content: center; padding: 12px; border-top: 1px solid var(--outline-variant); }
@media (max-width: 640px) {
.cl-time { flex-basis: auto; }
.cl-views { width: 100%; }
.cl-views .segmented-item { flex: 1; }
}
+183
View File
@@ -0,0 +1,183 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useMemo } from 'react';
import { apiFetch, bytes, timeAgo, type PageProps } from '../../lib/shared';
import { IcoCheck, IcoRefresh } from '../../components/icons';
import './connlog.css';
// Вкладка «Подключения»: журнал VPN выбранного сервера. Ведёт его awg-ctrl (internal/ctrl/connlog.go)
// по handshake и счётчикам WireGuard раз в 30 с, панель подставляет имена ключей и устройств sen://
// (/ui/connlog). Время отключения — последняя активность, точность около 30 с.
interface Who { pub_key: string; name: string; owner: string; ip: string; key?: string | null; device?: string | null; foreign?: boolean }
interface Ev extends Who { id: number; at: number; event: 'connect' | 'disconnect'; duration?: number | null; rx?: number | null; tx?: number | null }
interface Peer extends Who { online: boolean; last_seen: number | null }
interface Log { events: Ev[]; peers: Peer[]; since: number | null; keep_days: number }
const errText = (e: any): string => e?.response?.data?.error ?? 'Не удалось получить журнал';
// Кто это: пользователь — по имени, устройство sen:// — ключ и устройство.
function who(w: Who): { title: string; sub: string } {
if (w.name) return { title: w.name, sub: 'пользователь' };
if (w.foreign) return { title: 'Устройство другой панели', sub: w.owner };
if (w.owner) {
return { title: w.device || 'Удалённое устройство', sub: w.key ? `мастер-ключ «${w.key}»` : 'мастер-ключ удалён' };
}
return { title: `ключ ${w.pub_key.slice(0, 10)}…`, sub: 'не из панели' };
}
const pad = (n: number) => String(n).padStart(2, '0');
function when(ts: number): string {
const d = new Date(ts * 1000), now = new Date();
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (d.toDateString() === now.toDateString()) return `сегодня ${time}`;
const y = new Date(now); y.setDate(now.getDate() - 1);
if (d.toDateString() === y.toDateString()) return `вчера ${time}`;
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}${d.getFullYear() === now.getFullYear() ? '' : '.' + d.getFullYear()} ${time}`;
}
function duration(s: number): string {
if (s < 60) return `${s} сек`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} мин`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} ч ${m % 60} мин`;
return `${Math.floor(h / 24)} дн ${h % 24} ч`;
}
export default function ConnLogPage({ token, showMsg }: PageProps) {
const [view, setView] = useState<'events' | 'peers'>('events');
const [log, setLog] = useState<Log | null>(null);
const [more, setMore] = useState<Ev[]>([]); // подгруженные страницы старее первой
const [done, setDone] = useState(false); // старее событий нет
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState('');
// Первая страница: обновляем раз в 30 с — журнал пишется с тем же шагом.
const load = useCallback(async () => {
try {
const l = await apiFetch('GET', '/ui/connlog?limit=200', token) as Log;
setLog(l); setErr('');
if (l.events.length < 200) setDone(true);
} catch (e) { setErr(errText(e)); }
}, [token]);
useEffect(() => {
void load();
const t = setInterval(() => { void load(); }, 30000);
return () => clearInterval(t);
}, [load]);
const all = useMemo(() => {
const first = log?.events ?? [];
const seen = new Set(first.map(e => e.id));
return [...first, ...more.filter(e => !seen.has(e.id))];
}, [log, more]);
const loadMore = async () => {
const last = all[all.length - 1];
if (!last) return;
setBusy(true);
try {
const l = await apiFetch('GET', `/ui/connlog?limit=200&before=${last.id}`, token) as Log;
setMore(m => [...m, ...l.events]);
if (l.events.length < 200) setDone(true);
} catch (e) { showMsg(errText(e)); }
finally { setBusy(false); }
};
const q = query.trim().toLowerCase();
const match = (w: Who) => !q || [w.name, w.owner, w.ip, w.key ?? '', w.device ?? '', w.pub_key]
.some(s => s.toLowerCase().includes(q));
const events = all.filter(match);
const peers = (log?.peers ?? []).filter(match).sort((a, b) =>
Number(b.online) - Number(a.online) || (b.last_seen ?? 0) - (a.last_seen ?? 0));
const onlineNow = (log?.peers ?? []).filter(p => p.online).length;
return (
<div className="cl">
<div className="cl-toolbar">
<div className="segmented cl-views" role="radiogroup" aria-label="Вид">
<button type="button" role="radio" aria-checked={view === 'events'} className="segmented-item"
onClick={() => setView('events')}>{view === 'events' && <IcoCheck />}События</button>
<button type="button" role="radio" aria-checked={view === 'peers'} className="segmented-item"
onClick={() => setView('peers')}>{view === 'peers' && <IcoCheck />}Кто когда был</button>
</div>
<input className="field cl-search" type="search" placeholder="Имя, ключ, устройство или IP"
aria-label="Поиск" value={query} onChange={e => setQuery(e.target.value)} />
<button className="btn btn--tonal" onClick={() => void load()} aria-label="Обновить"><IcoRefresh /> Обновить</button>
</div>
{err && <p className="login-error cl-err">{err}</p>}
{!log && !err && <p className="cl-hint">Загрузка…</p>}
{log && (
<p className="cl-hint">
Сейчас онлайн: <b>{onlineNow}</b> из {log.peers.length}.
{' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней.</> : 'Журнал пуст: события появятся при первом подключении.'}
{' '}Время отключения — последняя активность, точность около 30 секунд.
</p>
)}
{log && view === 'events' && (
<ul className="cl-list">
{events.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'Событий пока нет'}</li>}
{events.map(e => {
const w = who(e);
const on = e.event === 'connect';
return (
<li className="cl-row" key={e.id}>
<span className="cl-time">{when(e.at)}</span>
<span className={`chip chip--${on ? 'online' : 'offline'} cl-chip`}>
<span className="chip-dot" />{on ? 'подключился' : 'отключился'}
</span>
<span className="cl-who">
<b>{w.title}</b>
<span className="cl-sub">{w.sub}{e.ip && <> · {e.ip}</>}</span>
</span>
{!on && (
<span className="cl-session">
{e.duration != null && <>сессия {duration(e.duration)}</>}
{/* rx/tx — со стороны сервера: то, что сервер принял, клиент отправил */}
{(e.tx || e.rx) ? <> · ↓ {bytes(e.tx ?? 0)} ↑ {bytes(e.rx ?? 0)}</> : null}
</span>
)}
</li>
);
})}
{!done && !q && all.length > 0 && (
<li className="cl-more">
<button className="btn btn--tonal" onClick={() => void loadMore()} disabled={busy}>
{busy ? 'Загрузка…' : 'Показать раньше'}
</button>
</li>
)}
</ul>
)}
{log && view === 'peers' && (
<ul className="cl-list">
{peers.length === 0 && <li className="cl-empty">{q ? 'Ничего не найдено' : 'На сервере нет пользователей'}</li>}
{peers.map(p => {
const w = who(p);
return (
<li className="cl-row" key={p.pub_key}>
<span className="cl-who">
<b>{w.title}</b>
<span className="cl-sub">{w.sub}{p.ip && <> · {p.ip}</>}</span>
</span>
{p.online ? (
<span className="chip chip--online cl-chip"><span className="chip-dot" />онлайн</span>
) : p.last_seen ? (
<span className="cl-seen">был {when(p.last_seen)} <span className="cl-sub">({timeAgo(p.last_seen)} назад)</span></span>
) : (
<span className="chip chip--offline cl-chip"><span className="chip-dot" />не подключался{log.since ? ` с ${when(log.since)}` : ''}</span>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "connlog",
"label": "Подключения",
"icon": "history",
"group": "main",
"order": 1.5
}
+29 -3
View File
@@ -4,7 +4,7 @@
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — { id, label, icon, order?, chrome? } (манифест: описание + порядок)
// metadata.json — { id, label, icon, group?, order?, requires?, chrome? } (манифест: описание, группа меню, порядок)
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
@@ -21,12 +21,21 @@ export interface TabChrome {
restart?: boolean;
}
// Группы меню: порядок здесь = порядок заголовков в сайдбаре. Новая группа — строка сюда.
export const TAB_GROUPS = [
{ id: 'main', label: 'Основное' },
{ id: 'server', label: 'О сервере' },
] as const;
export type TabGroupId = (typeof TAB_GROUPS)[number]['id'];
// Сериализуемая часть из metadata.json.
export interface TabMeta {
id: string;
label: string;
icon: string; // имя иконки → ICONS
order?: number; // порядок в меню (по возрастанию; без него — 0)
group: TabGroupId; // группа меню; без поля — 'main'
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
requires?: 'prometheus'; // вкладка в меню только при условии: 'prometheus' — встроенный Prometheus установлен
chrome?: TabChrome;
}
@@ -58,11 +67,20 @@ function toMeta(name: string, raw: unknown): TabMeta {
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
}
if (m.requires !== undefined && m.requires !== 'prometheus') {
throw new Error(`tabs/${name}/metadata.json: "requires" может быть только "prometheus"`);
}
const group = (m.group ?? 'main') as string;
if (!TAB_GROUPS.some(g => g.id === group)) {
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
}
return {
id: str('id'),
label: str('label'),
icon: str('icon'),
group: group as TabGroupId,
order: m.order as number | undefined,
requires: m.requires as 'prometheus' | undefined,
chrome: m.chrome as TabChrome | undefined,
};
}
@@ -80,7 +98,15 @@ export const TABS: TabDef[] = Object.entries(metas)
}
return { ...meta, Icon: ICONS[meta.icon], Page: page };
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// сначала по группе (как в TAB_GROUPS), внутри группы — по order
.sort((a, b) =>
TAB_GROUPS.findIndex(g => g.id === a.group) - TAB_GROUPS.findIndex(g => g.id === b.group)
|| (a.order ?? 0) - (b.order ?? 0));
// Вкладки по группам для меню; пустые группы не показываем.
export const TABS_BY_GROUP = TAB_GROUPS
.map(g => ({ ...g, tabs: TABS.filter(t => t.group === g.id) }))
.filter(g => g.tabs.length > 0);
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
const ids = TABS.map(t => t.id);
@@ -0,0 +1,125 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useMemo } from 'react';
import { apiFetch, genLabel, type NodeInfo } from '../../lib/shared';
// «Серверы для всех»: один набор правок на все мастер-ключи (POST /ui/masterkeys/servers).
// У каждого сервера — «у всех», «ни у кого» или «как есть» (если он стоит только у части ключей).
// Перед применением сервер считает, сколько ключей изменится и какие ключи API потеряют доступ к
// части из них (ключ виден API-ключу, только пока все его серверы в области).
type Pick = 'all' | 'none' | 'keep';
interface Preview { changed: number; total: number; hidden: { id: number; label: string; keys: number }[] }
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function BulkServersModal({ token, nodes, keys, defaults, onClose, onDone }: {
token: string;
nodes: NodeInfo[];
keys: { servers: number[] }[]; // живые мастер-ключи
defaults: number[] | null;
onClose: () => void;
onDone: (changed: number) => void;
}) {
const total = keys.length;
const count = (id: number) => keys.filter(k => k.servers.includes(id)).length;
const initial = (id: number): Pick => { const n = count(id); return n === total && total > 0 ? 'all' : n === 0 ? 'none' : 'keep'; };
const [picks, setPicks] = useState<Record<number, Pick>>(() => Object.fromEntries(nodes.map(n => [n.id, initial(n.id)])));
const [forNew, setForNew] = useState(true);
const [preview, setPreview] = useState<Preview | null>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const { add, remove } = useMemo(() => ({
add: nodes.filter(n => picks[n.id] === 'all' && count(n.id) < total).map(n => n.id),
remove: nodes.filter(n => picks[n.id] === 'none' && count(n.id) > 0).map(n => n.id),
}), [picks, nodes, keys]);
const all = nodes.filter(n => picks[n.id] === 'all').map(n => n.id);
const sameDefaults = JSON.stringify([...all].sort()) === JSON.stringify([...(defaults ?? [])].sort());
const body = { add, remove, ...(forNew && !sameDefaults ? { defaults: all.length ? all : null } : {}) };
const nothing = !add.length && !remove.length && !('defaults' in body);
useEffect(() => {
if (!add.length && !remove.length) { setPreview(null); setError(''); return; }
let live = true;
apiFetch('POST', '/ui/masterkeys/servers', token, { add, remove, dry_run: true })
.then(p => { if (live) { setPreview(p); setError(''); } })
.catch(e => { if (live) { setPreview(null); setError(errText(e)); } });
return () => { live = false; };
}, [token, add, remove]);
useEffect(() => {
const h = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const apply = async () => {
setBusy(true);
try { const r = await apiFetch('POST', '/ui/masterkeys/servers', token, body); onDone(r.changed ?? 0); }
catch (e) { setError(errText(e)); }
finally { setBusy(false); }
};
const option = (id: number, p: Pick, label: string) => (
<button type="button" className={`bs-opt${picks[id] === p ? ' bs-opt--on' : ''}`} aria-pressed={picks[id] === p}
onClick={() => setPicks(prev => ({ ...prev, [id]: p }))}>{label}</button>
);
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal bs-modal" role="dialog" aria-modal="true" aria-label="Серверы для всех ключей" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">Серверы для всех ключей</h3>
<p className="node-modal-hint">
Применится ко всем мастер-ключам сразу ({total}). Пользователям ничего делать не нужно: приложение получит
новый набор серверов при следующем обновлении подписки, ссылки и ключи остаются прежними.
</p>
<div className="bs-list">
{nodes.map(n => {
const c = count(n.id), partial = c > 0 && c < total;
return (
<div className="bs-row" key={n.id}>
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
<span className="chip-dot" />
</span>
<span className="bs-name">
<span className="server-pick-name">{n.health?.server || n.name}</span>
<span className="bs-sub">
{n.health?.ip && <code className="server-pick-ip">{n.health.ip}</code>}
{n.health?.gen && <span>{genLabel(n.health.gen)}</span>}
<span>сейчас у {c} из {total}</span>
</span>
</span>
<span className="bs-opts" role="group" aria-label={`Сервер ${n.name}`}>
{option(n.id, 'all', 'У всех')}
{partial && option(n.id, 'keep', 'Как есть')}
{option(n.id, 'none', 'Ни у кого')}
</span>
</div>
);
})}
</div>
<label className="bs-check">
<input type="checkbox" checked={forNew} onChange={e => setForNew(e.target.checked)} />
<span>Новым ключам — серверы «У всех»{all.length ? '' : ' (не выбрано ни одного — как раньше: все подключённые)'}</span>
</label>
{preview && (
<p className="node-modal-hint">Изменится ключей: {preview.changed} из {preview.total}.</p>
)}
{preview?.hidden.map(h => (
<p className="mk-notice" key={h.id}>
Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области.
Расширьте его область в «Настройках», на вкладке «API-ключи», если он ими управляет.
</p>
))}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
<button className="btn btn--text" onClick={onClose}>Отмена</button>
<button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}>
{busy ? 'Применяем…' : 'Применить'}
</button>
</div>
</div>
</div>
);
}
+488 -112
View File
@@ -1,51 +1,103 @@
// 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, genLabel, type AwgGen, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { apiFetch, copyText, bytes, timeAgo, getServerId, cmpVersion, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { qrDataUrl } from '../../lib/qr';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoMore } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import BulkServersModal from './BulkServersModal';
import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
interface MasterKey { id: number; label: string; device_limit: number; devices: number; created_at: number }
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
interface MasterKey { id: number; 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;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority; torrent?: boolean;
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
}
// Платформа и версия клиента: «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 ?? 'Ошибка запроса';
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
// Приоритет показываем, только когда он не обычный: «обычный» в каждой строке — шум
const bgOnly = (p?: Priority) => p === 'background' ? p : undefined;
const statusChip = (d: Device) => (
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{d.online ? 'онлайн' : d.lastHandshake ? timeAgo(d.lastHandshake) + ' назад' : 'никогда'}
</span>
);
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом;
// other — адрес с другого сервера, его надо подписать, иначе его примут за адрес выбранного.
const ipPick = (d: Device): { ip: string; serverId?: number; other: boolean } => {
const own = d.ips?.find(x => x.server_id === getServerId());
const pick = own ?? d.ips?.[0];
return { ip: pick?.ip ?? '—', serverId: pick?.server_id, other: !own && !!pick };
};
export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<MasterKey[]>([]);
const [enabled, setEnabled] = useState(true);
const [tls, setTls] = useState(false);
const [server, setServer] = useState<{ name: string; ip: string; gen: string } | null>(null);
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
// Маскировка первого пакета (I1) сервера из шапки: false — сервер её не умеет (старый awg-ctrl) или недоступен
const [masking, setMasking] = useState<'ready' | 'busy' | false>(false);
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); // узел с открытыми настройками
// Меню «⋮» ключа или устройства. position: fixed по кнопке: .table-card прокручивается вбок
// и обрезал бы меню у нижних строк. full — в меню ключа и «Скопировать ссылку» (в таблице;
// в карточке на телефоне ссылка — отдельной кнопкой).
const [menu, setMenu] = useState<{ kind: 'key' | 'device'; id: number; full: boolean; top: number; right: number; up: boolean } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [limitEdit, setLimitEdit] = useState('');
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
const [creating, setCreating] = useState(false);
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
const creatingRef = useRef(false);
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
const [defaults, setDefaults] = useState<number[] | null>(null); // серверы новых ключей из «Серверы для всех»
const [bulk, setBulk] = useState(false);
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const d = await apiFetch('GET', '/ui/masterkeys', token);
const list: MasterKey[] = d.keys ?? [];
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
// Сервер, который сейчас отдаётся по ключу (он пока единственный).
apiFetch('GET', '/health', token).then(h => setServer({ name: h.server, ip: h.ip, gen: h.gen })).catch(() => {});
const all = await Promise.all(list.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
if (seq !== loadSeq.current) return;
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setDefaults(d.defaults ?? null);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
const live = list.filter(k => !k.deleting);
const res = await Promise.allSettled(live.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token)));
if (seq !== loadSeq.current) return;
const bad = new Set<number>(), unreachable: Record<number, number[]> = {};
const fresh: Record<number, Device[]> = {};
res.forEach((r, i) => {
const id = live[i].id;
if (r.status === 'fulfilled') {
fresh[id] = r.value.devices ?? [];
if (r.value.unreachable?.length) unreachable[id] = r.value.unreachable;
} else bad.add(id);
});
// Не загрузилось — оставляем прежний список (устаревший лучше пустого), но помечаем.
setDevs(prev => Object.fromEntries(live.flatMap(k =>
fresh[k.id] ? [[k.id, fresh[k.id]]] : prev[k.id] ? [[k.id, prev[k.id]]] : [])));
setFailed(bad); setDown(unreachable);
} catch { if (seq === loadSeq.current) showMsg('Ошибка загрузки мастер-ключей'); }
}, [token, showMsg]);
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
@@ -55,17 +107,47 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [load, showMsg]);
const create = useCallback(async () => {
if (!label.trim()) return;
await act(async () => {
await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit) });
if (!label.trim() || creatingRef.current) return;
creatingRef.current = true; setCreating(true);
try { 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), ...(!defaults && ready.length ? { servers: ready } : {}),
});
setLabel('');
}, 'Мастер-ключ создан');
}, [label, limit, token, act]);
}, 'Мастер-ключ создан'); }
finally { creatingRef.current = false; setCreating(false); }
}, [label, limit, nodes, defaults, token, act]);
const live = useMemo(() => keys.filter(k => !k.deleting), [keys]);
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/masking', token).then(() => { if (alive) setMasking('ready'); }).catch(() => {});
return () => { alive = false; };
}, [token]);
// Новый случайный I1 на сервере из шапки. Интерфейс не трогается: I1 нужен только клиентам,
// sen:// подхватят его со следующим обновлением профиля (≤ 60 с), выданные vpn:// остаются со старым.
const regenMasking = async () => {
setMasking('busy');
try { await apiFetch('POST', '/api/masking', token); showMsg('Маскировка сменена'); }
catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось сменить маскировку'); }
finally { setMasking('ready'); }
};
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
const remove = useCallback(async (k: MasterKey) => {
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label);
}, [token, act]);
try {
const r = await apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token);
const pending: number[] = r.pending ?? [];
showMsg(pending.length ? `«${k.label}» отключён, ждём подтверждения: ${serverNames(pending)}` : 'Удалён: ' + k.label);
await load();
} catch (e) { showMsg(errText(e)); }
}, [token, load, showMsg, serverNames]);
const fetchLink = useCallback(async (k: MasterKey) => {
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
@@ -75,8 +157,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
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 });
setModal({ label: k.label, link, tls: t, dataUrl: qrDataUrl(link, 'H') });
} catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]);
@@ -85,6 +166,29 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]);
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
// поэтому сперва спросить и назвать, кого именно. Список устройств не загрузился — считаем по
// счётчику ключа и имён не называем, но спросить всё равно надо.
const saveLimit = useCallback(async (k: MasterKey) => {
const limit = Number(limitEdit);
const list = devs[k.id];
const have = list?.length ?? k.devices;
const extra = have - limit;
if (Number.isInteger(limit) && extra > 0) {
const who = list
? [...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(', ')
: `${extra} шт.`;
if (!confirm(`Лимит ${limit} меньше, чем устройств (${have}). Будут отвязаны подключившиеся последними: ${who}. Продолжить?`)) 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;
});
@@ -92,12 +196,62 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
useEffect(() => { if (token) load(); }, [token, load]);
// Пока какой-то ключ ждёт серверы, время от времени проверяем, не подтвердили ли они.
const waiting = keys.some(k => k.deleting);
useEffect(() => {
if (!waiting) return;
const t = setInterval(load, 15_000);
return () => clearInterval(t);
}, [waiting, load]);
const deletingChip = (k: MasterKey) => (
<span className="chip chip--offline" title="Ключ уже не работает; строка исчезнет, когда серверы подтвердят, что сняли его устройства">
<span className="chip-dot" />Удаляется — ждёт: {serverNames(k.deleting ?? [])}
</span>
);
useEffect(() => {
if (!menu) return;
menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
const close = () => setMenu(null);
const down = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) close(); };
const key = (e: KeyboardEvent) => {
if (e.key === 'Escape') { close(); document.querySelector<HTMLButtonElement>(`[data-menu-for="${menu.kind}-${menu.id}"]`)?.focus(); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const items = [...(menuRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])];
const i = items.indexOf(document.activeElement as HTMLButtonElement);
items[(i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length]?.focus();
};
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
document.removeEventListener('mousedown', down);
document.removeEventListener('keydown', key);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [menu]);
const openMenu = (kind: 'key' | 'device', id: number, el: HTMLElement, full = false) => {
if (menu?.kind === kind && menu.id === id) return setMenu(null);
const r = el.getBoundingClientRect();
const up = r.bottom + 160 > window.innerHeight; // у нижнего края — вверх от кнопки
setMenu({ kind, id, full, top: up ? r.top - 4 : r.bottom + 4, right: window.innerWidth - r.right, up });
};
const moreButton = (kind: 'key' | 'device', id: number, label: string, full = false) => (
<button className="btn-icon mk-more" aria-label={label} aria-haspopup="menu" aria-expanded={menu?.kind === kind && menu.id === id}
data-menu-for={`${kind}-${id}`} onClick={e => openMenu(kind, id, e.currentTarget, full)}><IcoMore /></button>
);
// В таблице — QR и «⋮» (остальное в меню), чтобы колонка действий всегда помещалась.
// Подсказки — .tip-wrap, не title: нативная появляется с задержкой и бесполезна на тач-экране.
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>
<span className="tip-wrap tip-wrap--end" data-tip="QR-код"><button className="btn-icon" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button></span>
{moreButton('key', k.id, `Ещё: ${k.label}`, true)}
</div>
);
@@ -106,6 +260,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
{/* Слева — настройки самого ключа */}
<section className="mk-box">
<h4 className="mk-box-title">Настройки ключа</h4>
<div className="mk-setting">
<div className="mk-setting-text">
<span className="mk-setting-name">UUID</span>
<code className="mk-uuid" title={k.uuid}>{k.uuid}</code>
<span className="mk-setting-hint">Постоянный: перевыпуск ссылки его не меняет</span>
</div>
<div className="mk-setting-ctl">
<button className="btn btn--tonal" onClick={() =>
copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
<IcoCopy /> Скопировать
</button>
</div>
</div>
<div className="mk-setting">
<div className="mk-setting-text">
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
@@ -114,8 +281,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<div className="mk-setting-ctl">
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
<button className="btn btn--tonal" onClick={() =>
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: Number(limitEdit) }), 'Лимит сохранён')}>
<button className="btn btn--tonal" onClick={() => saveLimit(k)}>
Сохранить
</button>
</div>
@@ -146,27 +312,21 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div>
</section>
{/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён.
TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[]
уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */}
{/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку —
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
<section className="mk-box">
<h4 className="mk-box-title">Включённые серверы</h4>
<label className="mk-server">
<input type="checkbox" checked disabled />
<span className="mk-server-name">{server?.name ?? 'Текущий сервер'}</span>
{server?.ip && <code className="mk-server-ip">{server.ip}</code>}
{server?.gen && <span className="mk-server-gen">{genLabel(server.gen as AwgGen)}</span>}
</label>
<span className="tip-wrap" data-tip="Появится вместе с поддержкой нескольких серверов">
<button className="btn mk-server-add" disabled><IcoPlus /> Добавить сервер</button>
</span>
<h4 className="mk-box-title">Серверы ключа</h4>
<ServerPicker nodes={nodes} selected={k.servers} onChange={(next, n, added) =>
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />
<p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p>
</section>
</div>
);
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
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' : ''}`}
<div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : ''}`}
role="progressbar" aria-label="Устройства" aria-valuemin={0}
aria-valuemax={k.device_limit} aria-valuenow={n}
title={`Устройств: ${n} из ${k.device_limit}`}>
@@ -179,45 +339,196 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div>
);
const newPsk = async (d: Device) => {
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)); }
};
const revoke = (d: Device) => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
};
const deviceActions = (d: Device) => (
<div className="actions">
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={() =>
act(() => apiFetch('POST', `/ui/devices/${d.id}/psk`, token), 'PSK обновлён')}><IcoRefresh /></button>
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
}}><IcoTrash /></button>
<span className="tip-wrap tip-wrap--end" data-tip="Новый PSK"><button className="btn-icon" aria-label="Новый PSK" onClick={() => newPsk(d)}><IcoRefresh /></button></span>
<span className="tip-wrap tip-wrap--end" data-tip="Отозвать устройство"><button className="btn-icon btn-icon--danger" aria-label="Отозвать устройство" onClick={() => revoke(d)}><IcoTrash /></button></span>
</div>
);
const caret = (k: MasterKey) => (
// Короткий ID устройства: полностью — в подсказке, по клику копируется
const deviceId = (d: Device) => (
<span className="tip-wrap tip-wrap--start" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
{d.device_id.slice(0, 8)}…
</button>
</span>
);
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
// по клику копируется.
const nameCell = (k: MasterKey) => (
<span className="name-stack">
{k.label}
<span className="tip-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать UUID ${k.uuid}`}
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
{k.uuid.slice(0, 8)}…
</button>
</span>
</span>
);
// Данные по ключу неполные: устройства не загрузились или сервер не ответил на статистику.
const dataFlag = (k: MasterKey) => {
if (failed.has(k.id)) return <span className="mk-flag" title="Список устройств не загрузился — показан последний известный"> · устройства не загрузились</span>;
const d = down[k.id];
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
};
// Серверы ключа, куда ещё не добавлено устройство: был не в сети, когда оно подключалось или меняло
// ключ; панель добавит его сама, когда сервер появится (reconcile). Без ips (старая панель) — неизвестно.
const missingOn = (k: MasterKey, d: Device) => d.ips ? k.servers.filter(s => !d.ips!.some(x => x.server_id === s)) : [];
// «из 3 серверов», «из 21 сервера»
const ofServers = (n: number) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'сервера' : 'серверов'}`;
const syncFlag = (k: MasterKey, d: Device) => {
const miss = missingOn(k, d);
if (miss.length) return `${k.servers.length - miss.length} из ${ofServers(k.servers.length)}`;
return d.servers_ok < d.servers_total ? `${d.servers_ok} из ${ofServers(d.servers_total)}` : '';
};
const syncTip = (k: MasterKey, d: Device) => {
const miss = missingOn(k, d);
return miss.length ? `Нет на ${miss.map(s => serverName(nodes, s)).join(', ')} — добавится само, когда сервер будет на связи`
: 'Не на всех серверах ключа — добавится само, когда они будут на связи';
};
// Серверы ключа — в его строке под колонками IP и «Платформа»: «все серверы» (у ключа с одним
// сервером — его имя) светло-зелёным или «2 из 3 серверов» тёплым, если на каком-то ещё нет всех
// устройств ключа.
// Имена — в подсказке.
const keyServers = (k: MasterKey, list: Device[]) => {
const behind = (s: number) => list.some(d => missingOn(k, d).includes(s));
const late = k.servers.filter(behind);
const ready = k.servers.filter(s => !behind(s));
const names = (ids: number[]) => ids.map(s => serverName(nodes, s)).join(', ');
const text = late.length ? `${ready.length} из ${ofServers(k.servers.length)}`
: k.servers.length === 1 ? serverName(nodes, k.servers[0]) : 'все серверы';
const tip = late.length ? `Есть: ${names(ready) || 'нигде'}. Нет: ${names(late)} — добавятся сами, когда сервер будет на связи`
: k.servers.length > 1 ? names(k.servers) : null;
const chip = <span className={`chip ${late.length ? 'chip--warm' : 'chip--online'} mk-srv`}>{text}</span>; // всё на месте — светло-зелёный
return tip ? <span className="tip-wrap tip-wrap--end" data-tip={tip}>{chip}</span> : chip;
};
// Статус ключа по его устройствам: онлайн / когда были / «нет устройств», плюс фоновый приоритет и торренты
const keyStatus = (list: Device[], empty: boolean) => {
if (!list.length) return empty ? <span className="mk-flag">нет устройств</span> : null;
const on = list.filter(d => d.online).length;
const last = Math.max(...list.map(d => d.lastHandshake || 0));
const torrent = list.some(d => d.torrent);
const prio = bgOnly(list.some(d => d.priority === 'background') ? 'background' : undefined);
return (
<>
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
</span>
<PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} />
</>
);
};
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
const summary = (k: MasterKey, list: Device[], empty: boolean) => {
if (!list.length) return <><td>{keyStatus(list, empty)}</td><td colSpan={2}>{keyServers(k, list)}</td><td></td><td></td></>;
return (
<>
<td><span className="user-chips">{keyStatus(list, empty)}</span></td>
<td colSpan={2}>{keyServers(k, list)}</td>
<td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td>
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
</>
);
};
// Один адрес — выбранного в шапке сервера, остальные — «+N» и подсказка со всеми. Адрес с другого
// сервера подписан его именем строкой ниже, иначе его примут за адрес выбранного.
const ipCell = (d: Device) => {
const all = d.ips ?? [];
const { ip, serverId, other } = ipPick(d);
const body = (
<span className="name-stack">
<span className="mk-ip-line">
<code className="mk-ip">{ip}</code>
{all.length > 1 && <span className="mk-ip-more">+{all.length - 1}</span>}
</span>
{other && serverId !== undefined && <span className="mk-sub">{serverName(nodes, serverId)}</span>}
</span>
);
return all.length > 1
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{body}</span>
: body;
};
// Самая новая версия SenAWG среди устройств панели: кто отстал — тёплой плашкой, как ноды в меню серверов
const newest = useMemo(() => Object.values(devs).flat().map(d => d.version).filter(Boolean)
.reduce<string | null>((a, v) => !a || cmpVersion(v, a) > 0 ? v : a, null), [devs]);
const versionTag = (d: Device, tip: 'start' | 'end') => {
if (!d.version) return null;
const old = !!newest && cmpVersion(d.version, newest) < 0;
return old
? <span className={`tip-wrap tip-wrap--${tip}`} data-tip={`Есть новее: ${newest} — обнови SenAWG на устройстве`}><span className="chip chip--warm mk-ver">{d.version}</span></span>
: <span className="mk-sub">{d.version}</span>;
};
const platformCell = (d: Device) => (
<span className="name-stack">
{d.platform || '—'}
{versionTag(d, 'end')}
</span>
);
// Раскрывать нечего — вместо стрелки пустое место той же ширины, имена остаются в одну линию
const caret = (k: MasterKey, n: number) => n === 0 ? <span className="mk-caret-gap" aria-hidden="true" /> : (
<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>
// Телефон: устройство — строка списка в карточке ключа, а не карточка в карточке. Имя, статус и «⋮»;
// ниже мелко — платформа, версия, IP; трафик, приоритет и то, на скольких серверах оно есть.
const deviceRow = (k: MasterKey, d: Device) => {
const all = d.ips ?? [];
const { ip, serverId, other } = ipPick(d);
return (
<li className="mk-dev" key={d.id}>
<div className="mk-dev-head">
<span className="name-stack mk-dev-title">
<span className="mk-dev-name">{d.device_name || 'Устройство'}</span>
{deviceId(d)}
</span>
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
{d.rekey_requested ? ' · ждёт смены ключа' : ''}</span>
{statusChip(d)}
{moreButton('device', d.id, `Ещё: ${d.device_name || 'устройство'}`)}
</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 className="mk-dev-meta">
<span>{d.platform || '—'}</span>
{versionTag(d, 'start')}
<span aria-hidden="true">·</span>
<code className="mk-ip">{ip}</code>
{all.length > 1 && <span className="mk-ip-more" aria-label={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(', ')}>+{all.length - 1}</span>}
{other && serverId !== undefined && <span>{serverName(nodes, serverId)}</span>}
</div>
{deviceActions(d)}
</div>
</li>
);
<div className="mk-dev-meta">
<span className="mk-dev-traffic">↓ {bytes(d.rx)} ↑ {bytes(d.tx)}</span>
<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
{syncFlag(k, d) && <span>{syncFlag(k, d)}</span>}
{d.rekey_requested && <span>ждёт смены ключа</span>}
</div>
</li>
);
};
return (
<>
@@ -228,13 +539,29 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
)}
{masking && (
<div className="mk-warn" role="note">
<span className="mk-warn-text">
<b>Маскировка первого пакета · {serverName(nodes, getServerId() ?? 0)}</b>
Перед подключением клиент шлёт пакет, похожий на обычный трафик. Новый шаблон устройства sen://
получат в течение минуты.
</span>
<button className="btn btn--tonal" onClick={regenMasking} disabled={masking === 'busy'}>
<IcoRefresh /> Сменить
</button>
</div>
)}
<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>
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
<button className="btn btn--tonal" onClick={load}><IcoRefresh /> Обновить</button>
<button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length}
title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button>
</div>
</div>
@@ -243,49 +570,56 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<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>
<th>Имя</th><th>Статус</th><th>IP</th><th>Платформа</th>
<th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
<tr><td colSpan={8} 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 />{nameCell(k)}</span></td>
<td colSpan={7}>{deletingChip(k)}</td>
</tr>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
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 colSpan={6}></td>
<td>{meter(k, list.length)}</td>
<td><span className="mk-name">{caret(k, count)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
{summary(k, list, count === 0)}
<td>{meter(k, count)}</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>}
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={8}>{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>
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 ? ' mk-last' : ''}`}>
<td>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
<span className="mk-indent">
<span className="name-stack">
{d.device_name || 'Устройство'}
{/* пометки — в строке ID, не рядом с именем: так колонка «Имя» уже и таблица влезает */}
<span className="mk-id-line">
{deviceId(d)}
{syncFlag(k, d) && <span className="tip-wrap tip-wrap--start mk-flag" data-tip={syncTip(k, d)}>· {syncFlag(k, d)}</span>}
{d.rekey_requested && <span className="mk-flag">· ждёт смены ключа</span>}
</span>
</span>
</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>}</td>
<td><span className="user-chips">{statusChip(d)}<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
<td>{ipCell(d)}</td>
<td>{platformCell(d)}</td>
<td>{bytes(d.rx)}</td>
<td>{bytes(d.tx)}</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>
);
})}
@@ -293,37 +627,79 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</table>
</div>
{/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */}
{/* Телефон и планшет: таблица скрыта (.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">{nameCell(k)}</span>
</div>
<div className="mk-master-sum">{deletingChip(k)}</div>
</li>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
const isFolded = folded.has(k.id);
return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
{caret(k)}
{caret(k, count)}
<IcoKey />
<span className="mk-master-label">{k.label}</span>
{meter(k, list.length)}
{actions(k)}
<span className="mk-master-label">{nameCell(k)}</span>
{meter(k, count)}
</div>
<div className="mk-master-sum">
{keyStatus(list, count === 0)}
{keyServers(k, list)}
{dataFlag(k)}
</div>
<div className="mk-master-actions">
<button className="btn btn--tonal" onClick={() => showQR(k)}><IcoQR /> QR</button>
<button className="btn btn--outline" onClick={() => copyLink(k)}><IcoCopy /> Ссылка</button>
{moreButton('key', k.id, `Ещё: ${k.label}`)}
</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>
)}
{!isFolded && list.length > 0 && <ul className="mk-devs">{list.map(d => deviceRow(k, d))}</ul>}
</li>
);
})}
</ul>
)}
{menu && (() => {
const run = (f: () => void) => () => { setMenu(null); f(); };
const at = { top: menu.top, right: menu.right };
const cls = `mk-menu${menu.up ? ' mk-menu--up' : ''}`;
if (menu.kind === 'device') {
const d = Object.values(devs).flat().find(x => x.id === menu.id);
if (!d) return null;
return (
<div className={cls} role="menu" aria-label={`Устройство ${d.device_name}`} ref={menuRef} style={at}>
<button role="menuitem" className="mk-menu-item" onClick={run(() => newPsk(d))}><IcoRefresh /> Новый PSK</button>
<button role="menuitem" className="mk-menu-item mk-menu-item--danger" onClick={run(() => revoke(d))}><IcoTrash /> Отозвать</button>
</div>
);
}
const k = keys.find(x => x.id === menu.id);
if (!k) return null;
return (
<div className={cls} role="menu" aria-label={`Мастер-ключ ${k.label}`} ref={menuRef} style={at}>
{menu.full && <button role="menuitem" className="mk-menu-item" onClick={run(() => copyLink(k))}><IcoCopy /> Скопировать ссылку</button>}
<button role="menuitem" className="mk-menu-item" onClick={run(() => openTools(k))}><IcoSettings /> Настройки ключа</button>
<button role="menuitem" className="mk-menu-item mk-menu-item--danger" onClick={run(() => remove(k))}><IcoTrash /> Удалить</button>
</div>
);
})()}
{bulk && (
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
)}
{modal && (
<div className="qr-backdrop" onClick={() => setModal(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
@@ -332,8 +708,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
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--text" onClick={() => setModal(null)}>Закрыть</button>
<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>
+152 -144
View File
@@ -7,23 +7,35 @@ 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;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-error-container);
background: var(--error-container);
border-radius: var(--shape-large);
padding: 10px 16px;
}
.mk-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
/* Предупреждение о маскировке: тёплый tertiary, как чип «устарела» у версии сервера */
.mk-warn {
display: flex; align-items: center; gap: 12px;
background: var(--tertiary-container); color: var(--on-tertiary-container);
border-radius: var(--shape-large);
padding: 10px 12px 10px 16px;
}
.mk-warn-text {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
}
.mk-warn-text b { font-weight: var(--weight-medium); }
.mk-warn .btn { flex-shrink: 0; }
.mk-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); 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 .td-actions { width: 80px; } /* QR и «⋮» */
.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 td { background: var(--surface-container); font-weight: var(--weight-medium); 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); }
.mk-name .mk-caret { margin-left: -8px; }
@@ -31,37 +43,48 @@ LICENSE file in the root directory of this source tree.
.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 { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.mk-id-line { display: inline-flex; align-items: baseline; gap: 6px; }
/* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */
.mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; }
.mk-ip { font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
/* вторая строка ячейки: сервер чужого адреса, версия клиента */
.mk-sub { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mk-ip-line { display: inline-flex; align-items: center; gap: 6px; }
.mk-ip-more {
font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface-variant);
background: var(--surface-container-high); border-radius: var(--shape-full); padding: 0 6px;
}
.mk-srv, .mk-ver { font-weight: var(--weight-regular); }
.mk-ver { align-self: flex-start; padding-top: 0; padding-bottom: 0; font-family: var(--mono); }
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
.mk-tr-device 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-tr-device.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, .mk-devs { 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;
border-radius: var(--shape-extra-large);
padding: 12px 16px;
}
/* родитель */
/* шапка ключа: стрелка, ключ, метка с коротким UUID, полоска устройств */
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
.mk-master-label {
font-size: 16px;
font-weight: 500;
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: var(--weight-medium);
color: var(--on-surface);
flex: 1;
min-width: 0;
@@ -74,13 +97,38 @@ LICENSE file in the root directory of this source tree.
border: 0; border-radius: 50%;
background: transparent;
color: var(--neutral-text);
font-size: 16px;
font-size: var(--body-large-size); line-height: var(--body-large-line);
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s;
transition: transform var(--duration-short3) var(--ease-standard);
}
.mk-caret:hover { background: var(--state-neutral); }
.mk-caret--folded { transform: rotate(-90deg); }
.mk-caret-gap { width: 28px; flex-shrink: 0; } /* ключ без устройств: место стрелки */
.mk-name .mk-caret-gap { margin-left: -8px; }
/* Меню «⋮» ключа (MD3 menu): плавает над таблицей, у кнопки справа */
.mk-menu {
position: fixed; z-index: 30;
min-width: 200px; padding: 6px;
background: var(--surface-container);
border-radius: var(--shape-large);
box-shadow: var(--elevation-2);
animation: mk-menu-in var(--duration-short4) var(--ease-emphasized-decelerate);
transform-origin: top right;
}
.mk-menu--up { transform: translateY(-100%); transform-origin: bottom right; }
@keyframes mk-menu-in { from { opacity: 0; scale: .92; } }
.mk-menu-item {
display: flex; align-items: center; gap: 12px; width: 100%;
min-height: 40px; padding: 0 12px;
border: 0; border-radius: var(--shape-medium); background: transparent;
font-family: var(--font); font-size: var(--label-large-size); line-height: var(--label-large-line); font-weight: var(--weight-medium);
color: var(--on-surface); text-align: left; cursor: pointer;
}
.mk-menu-item svg { color: var(--on-surface-variant); flex-shrink: 0; }
.mk-menu-item--danger, .mk-menu-item--danger svg { color: var(--error); }
@media (pointer: coarse) { .mk-menu-item { min-height: 48px; } }
@media (prefers-reduced-motion: reduce) { .mk-menu { animation: none; } }
.mk-tools {
display: grid;
@@ -94,10 +142,10 @@ LICENSE file in the root directory of this source tree.
display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: 16px;
border-radius: var(--shape-large);
padding: 12px 16px 14px;
}
.mk-box-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
.mk-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
.mk-box .mk-box-title { margin-bottom: 2px; }
/* строка настройки: слева название и пояснение, справа контрол */
.mk-setting {
@@ -108,21 +156,26 @@ LICENSE file in the root directory of this source tree.
.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-name { font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mk-setting-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
.mk-uuid {
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); 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: var(--label-large-size); line-height: var(--label-large-line); }
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка».
/* прогресс занятых слотов с числом внутри: обычная заливка primary, цветом — только когда мест
не осталось (тёплый: это повод присмотреться, а не ошибка).
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
.mk-meter {
--mk-w: 136px;
--meter-fill: var(--success-dot); /* < 2/3 — зелёный */
--meter-on: #2a2118; /* цвет числа поверх заливки */
--meter-fill: var(--primary);
--meter-on: var(--on-primary); /* цвет числа поверх заливки */
position: relative;
width: var(--mk-w); height: 20px;
border-radius: var(--r-pill);
border-radius: var(--shape-full);
background: var(--outline-variant);
overflow: hidden;
flex-shrink: 0;
@@ -131,7 +184,7 @@ LICENSE file in the root directory of this source tree.
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--meter-fill);
overflow: hidden;
transition: width 0.25s, background 0.25s;
transition: width var(--duration-medium1) var(--ease-standard), background var(--duration-medium1) var(--ease-standard);
}
/* числа по краям внутри полоски: текущее слева, лимит справа */
.mk-meter-text {
@@ -140,103 +193,62 @@ LICENSE file in the root directory of this source tree.
box-sizing: border-box;
padding: 0 11px;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; font-weight: 500;
font-size: var(--label-medium-size); line-height: var(--label-medium-line); font-weight: var(--weight-medium);
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-meter--full { --meter-fill: var(--warn-dot); --meter-on: #2a2118; } /* все места заняты */
/* серверы, отдаваемые по ключу (заглушка) */
.mk-server {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container);
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
color: var(--on-surface);
}
.mk-server input { accent-color: var(--primary); margin: 0; }
.mk-server-name { font-weight: 500; }
.mk-server-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); }
.mk-server-ip { font-family: var(--mono); margin-left: auto; }
.mk-server-add {
width: 100%; justify-content: center;
background: transparent;
border: 1px dashed var(--outline);
color: var(--neutral-dot);
cursor: not-allowed;
}
.mk-box .tip-wrap { width: 100%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
.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-master-sum { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
.mk-master-sum:empty { display: none; }
.mk-master-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.mk-master-actions .btn { flex: 1; }
.mk-tree .mk-more { width: 40px; height: 40px; }
/* на телефоне .tip-wrap по умолчанию во всю ширину — здесь чипы и ID идут в строку */
.mk-tree .tip-wrap { width: auto; }
.mk-device {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--surface-container);
border-radius: 14px;
padding: 8px 14px;
/* устройства ключа — строками списка под ним, разделены линией */
.mk-devs { margin: 12px -16px -12px; border-top: 1px solid var(--outline-variant); }
.mk-dev { padding: 10px 16px; }
.mk-dev + .mk-dev { border-top: 1px solid var(--outline-variant); }
.mk-dev-head { display: flex; align-items: center; gap: 8px; }
.mk-dev-title { flex: 1; min-width: 0; }
.mk-dev-name {
font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.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-dev-head .mk-more { margin: -4px -8px -4px 0; }
.mk-dev-meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 4px;
font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
}
.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-dev-meta .chip { font-size: var(--body-small-size); }
.mk-dev-traffic { font-family: var(--mono); white-space: pre; }
.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) {
/* ── телефон и планшет (design.md, часть IV): карточка на ключ, устройства строками, тач-цели ≥ 44px ── */
/* compact и medium (< 840px): карточки вместо таблицы */
@media (max-width: 839px) {
.mk-warn { flex-wrap: wrap; padding: 12px 12px 12px 16px; }
.mk-warn-text { flex-basis: 100%; }
.mk-warn .btn { margin-left: auto; }
.mk-tree { display: flex; }
}
@media (pointer: coarse) {
.mk-tree .mk-more { width: 44px; height: 44px; }
}
@media (max-width: 599px) {
.mk-limit-input { width: 100% !important; }
.mk-root { padding: 12px; }
.mk-devs { margin: 12px -12px -12px; }
.mk-dev { padding: 10px 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-caret-gap { width: 44px; margin-left: -8px; }
.mk-meter { --mk-w: 112px; height: 24px; }
/* настройки узла: поля и кнопки на всю ширину */
@@ -246,36 +258,32 @@ LICENSE file in the root directory of this source tree.
.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; }
/* «Серверы для всех» (BulkServersModal) */
.bs-modal { max-width: 620px; }
.bs-list { display: flex; flex-direction: column; gap: 8px; }
.bs-row {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container); border-radius: var(--shape-medium); padding: 8px 8px 8px 14px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
}
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.bs-sub .server-pick-ip { margin-left: 0; }
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
.bs-opt {
min-height: 36px; padding: 0 12px;
font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--shape-full);
cursor: pointer; white-space: nowrap;
}
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: var(--weight-medium); }
.bs-check { display: flex; align-items: center; gap: 8px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); cursor: pointer; }
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
@media (max-width: 599px) {
.bs-row { flex-wrap: wrap; }
.bs-opts { width: 100%; }
.bs-opt { flex: 1; }
}
+1
View File
@@ -2,5 +2,6 @@
"id": "masterkeys",
"label": "Мастер-ключи",
"icon": "key",
"group": "main",
"order": 1.5
}
+317
View File
@@ -0,0 +1,317 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch, getServerId, openTab, announcePrometheus, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoGear } from '../../components/icons';
import type { MonitorStatus as Status } from '../settings/prometheus';
import LineChart from '../../components/LineChart';
import ServerSelect, { ALL_SERVERS } from '../../components/ServerSelect';
import './monitor.css';
// Вкладка «Монитор»: основные графики со встроенного Prometheus. Ставится и удаляется он в «Настройках»,
// раздел «Prometheus» (tabs/settings/prometheus); пока его нет, здесь — карточка со ссылкой туда.
// Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go).
type Pts = [number, number][];
interface Overview {
range: string; step: number; from: number; to: number;
servers: {
id: number; name: string; ok: boolean; down: number | null; up: number | null; online: number | null; heavy: number | null; congested: boolean;
cpu: number | null; mem: number | null; cores: number | null; mem_total: number | null;
used_down: number | null; used_up: number | null; // байт за весь период
}[];
total: Series;
}
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts }
type Row = Overview['servers'][number];
type NumKey = 'down' | 'up' | 'online' | 'heavy' | 'cpu' | 'mem' | 'cores' | 'mem_total' | 'used_down' | 'used_up';
// «Все серверы» — сводка «сейчас» для плиток: скорости, устройства, ядра, память и объём складываем,
// загрузку процессора и памяти — среднее по серверам (как и ряды total в monitor.go).
function sumUp(rows: Row[]): Row | undefined {
if (rows.length <= 1) return rows[0];
const sum = (k: NumKey) => rows.some(r => r[k] != null) ? rows.reduce((n, r) => n + (r[k] ?? 0), 0) : null;
const avg = (k: NumKey) => {
const v = rows.map(r => r[k]).filter((x): x is number => x != null);
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
};
return {
id: ALL_SERVERS, name: 'Все серверы', ok: rows.every(r => r.ok), congested: rows.some(r => r.congested),
down: sum('down'), up: sum('up'), online: sum('online'), heavy: sum('heavy'), cpu: avg('cpu'), mem: avg('mem'),
cores: sum('cores'), mem_total: sum('mem_total'), used_down: sum('used_down'), used_up: sum('used_up'),
};
}
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней']];
const nf = (v: number, d = 1) => v.toLocaleString('ru-RU', { maximumFractionDigits: d });
// Скорость в битах/с → «12,4 Мбит/с»
function rate(v: number, d = 1): string {
if (v >= 1e9) return `${nf(v / 1e9, d)} Гбит/с`;
if (v >= 1e6) return `${nf(v / 1e6, d)} Мбит/с`;
if (v >= 1e3) return `${nf(v / 1e3, d)} Кбит/с`;
return `${nf(v, 0)} бит/с`;
}
const lastOf = (p: Pts) => (p.length ? p[p.length - 1][1] : null);
// Полоска нагрузки: цвет заливки — серьёзность (норма → внимание → опасно), дорожка — светлая ступень той же шкалы.
// Цифра рядом всегда есть: цвет не единственный носитель смысла.
const level = (v: number) => (v >= 90 ? 'mon-bar--bad' : v >= 70 ? 'mon-bar--warn' : '');
function Bar({ v, label }: { v: number | null | undefined; label: string }) {
const pct = v == null ? 0 : Math.min(100, Math.max(0, v));
return (
<span className="mon-bar" role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100}
aria-valuenow={v == null ? undefined : Math.round(pct)}>
<i className={v == null ? '' : level(pct)} style={{ width: `${pct}%` }} />
</span>
);
}
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
// Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт
function vol(b: number): string {
if (b >= 1e12) return `${nf(b / 1e12, 2)} ТБ`;
if (b >= 1e9) return `${nf(b / 1e9, 1)} ГБ`;
return `${nf(b / 1e6, 0)} МБ`;
}
export default function MonitorPage({ token }: PageProps) {
const [status, setStatus] = useState<Status | null>(null);
const [range, setRange] = useState('1h');
const [ov, setOv] = useState<Overview | null>(null);
const [ovErr, setOvErr] = useState('');
const [loading, setLoading] = useState(false);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
// Чьи графики: сервер (по умолчанию — выбранный в шапке) или ALL_SERVERS. Выбор свой, шапку не меняет.
const [view, setView] = useState(() => getServerId() ?? 0);
const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; announcePrometheus(s.ready); if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token]);
const loadOverview = useCallback(async () => {
setLoading(true);
try {
// Без ?server= monitor.go отдаёт все серверы: total — сумма (процессор и память — среднее), servers — каждый
const q = view === ALL_SERVERS ? '' : `&server=${view}`;
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}${q}`, token) as Overview;
if (alive.current) { setOv(o); setOvErr(''); }
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
finally { if (alive.current) setLoading(false); }
}, [token, range, view]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
// Серверы для выбора (и их связь — точки на плашках)
useEffect(() => {
const load = () => apiFetch('GET', '/ui/nodes', token)
.then(r => { if (alive.current) setNodes(r.nodes ?? []); })
.catch(() => { /* выбор необязателен — останется текущий сервер */ });
void load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, [token]);
// Пока Prometheus готов — обновляем графики; пока ставится — следим за ходом установки.
useEffect(() => {
if (!status?.ready) return;
void loadOverview();
const t = setInterval(() => { void loadOverview(); }, 15000);
return () => clearInterval(t);
}, [status?.ready, loadOverview]);
// Пока Prometheus нет — раз в 10 с проверяем: его могли поставить в «Настройках»
useEffect(() => {
if (!status || status.ready) return;
const t = setInterval(() => { void loadStatus(); }, 10000);
return () => clearInterval(t);
}, [status, loadStatus]);
// Prometheus нет — графиков не будет: ведём в «Настройки», где он ставится
const setup = status && !status.ready && (
<section className="mon-card" aria-labelledby="mon-setup">
<div className="mon-head">
<h3 className="mon-title" id="mon-setup">Мониторинг</h3>
<span className="chip chip--offline">
<span className="chip-dot" />{status.job?.running && status.job.action === 'install' ? 'ставится' : 'не подключён'}
</span>
</div>
<p className="mon-hint">
Графики — скорость, устройства онлайн, нагрузка на канал — строит встроенный Prometheus.
Он ставится в «Настройках», на вкладке «Prometheus»; графики появятся здесь через минуту-две после установки.
</p>
<div className="mon-actions">
<button className="btn btn--primary" onClick={() => openTab('settings', 'prom')}>
<IcoGear /> Открыть настройки
</button>
</div>
</section>
);
// ───────── дашборд ─────────
// Один сервер или сводка по всем (sumUp) — плитки читают одно и то же
const all = view === ALL_SERVERS;
const one = all ? sumUp(ov?.servers ?? []) : ov?.servers[0];
// Кого нет в сумме: сервер не отдал метрики (ok = false) или на связи, а рядов нет совсем (старый агент)
const silent = !all || !ov ? [] : [
...ov.servers.filter(r => !r.ok).map(r => r.name),
...nodes.filter(n => n.online && !ov.servers.some(r => r.id === n.id)).map(n => n.health?.server || n.name),
];
const data = ov?.total ?? null;
const cur = data && {
// Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
down: one?.down ?? lastOf(data.down), up: one?.up ?? lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy),
busy: !!one?.congested,
};
const used = one?.used_down != null && one.used_up != null ? one.used_down + one.used_up : null;
// Одна единица на всю ось (Мбит/с, а не «50 Мбит» у каждой риски): выбирается по максимуму ряда
const peak = data ? Math.max(0, ...data.down.map(p => p[1]), ...data.up.map(p => p[1])) : 0;
const unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
const dashboard = status?.ready && (
<>
{/* Период слева задаёт окно для всего ниже; справа — чьи графики: кнопка и меню, как выбор
сервера в шапке, «Все серверы» — сводка по всем */}
<div className="mon-toolbar">
<div className="mon-filters" role="group" aria-label="Период">
{RANGES.map(([id, label]) => (
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
))}
</div>
{nodes.length > 0 && (
<ServerSelect name="mon-server-menu" nodes={nodes} value={view} onChange={setView} all pickOffline />
)}
</div>
{ovErr && <p className="login-error">{ovErr}</p>}
{ov && !all && !one?.ok && (
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
)}
{ov && all && (ov.servers.length === 0 || silent.length > 0) && (
<p className="mon-hint mon-notice">
{ov.servers.length === 0 ? 'Метрики сейчас не отдаёт ни один сервер.'
: `Не отдают метрики: ${silent.join(', ')} — в сумме их нет.`}
</p>
)}
<div className="mon-tiles">
<div className="mon-tile">
<span className="mon-tile-label">Трафик сейчас</span>
{/* Стрелки окрашены как серии графика «Трафик» ниже */}
<div className="mon-tile-rates">
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dir" style={{ color: 'var(--series-1)' }} role="img" aria-label="загрузка">↓</span>
{cur?.down != null ? rate(cur.down) : '—'}
</span>
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dir" style={{ color: 'var(--series-2)' }} role="img" aria-label="отдача">↑</span>
{cur?.up != null ? rate(cur.up) : '—'}
</span>
</div>
<span className="mon-tile-value mon-tile-rate mon-tile-total">
{used != null ? vol(used) : '—'}
<span className="mon-tile-sub">потрачено за {RANGES.find(([id]) => id === range)?.[1]}</span>
</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Устройства</span>
{/* Точки окрашены как серии графика «Устройства» ниже */}
<div className="mon-tile-rates">
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dot" style={{ background: 'var(--series-1)' }} aria-hidden="true" />
{cur?.online != null ? nf(cur.online, 0) : '—'}
<span className="mon-tile-sub">онлайн</span>
</span>
<span className="mon-tile-value mon-tile-rate">
<span className="mon-tile-dot" style={{ background: 'var(--series-2)' }} aria-hidden="true" />
{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}
<span className="mon-tile-sub">в фоне · {cur?.busy ? 'канал занят' : 'канал свободен'}</span>
</span>
</div>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Нагрузка</span>
<div className="mon-tile-rates">
<div className="mon-tile-meter">
<span className="mon-tile-value mon-tile-rate">
{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}
<span className="mon-tile-sub">процессор{all ? ` · в среднем по ${ov?.servers.length ?? 0}` : one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}</span>
</span>
<Bar v={one?.cpu} label="Загрузка процессора" />
</div>
<div className="mon-tile-meter">
<span className="mon-tile-value mon-tile-rate">
{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}
<span className="mon-tile-sub">память{all ? ` · в среднем по ${ov?.servers.length ?? 0}` : one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}</span>
</span>
<Bar v={one?.mem} label="Занято оперативной памяти" />
</div>
</div>
</div>
</div>
{/* Сводка по всем — и разбивка: кто сколько качает и насколько загружен */}
{all && ov && ov.servers.length > 1 && (
<section className="mon-chart mon-per-server" aria-labelledby="mon-per">
<div className="mon-chart-head"><span className="mon-chart-title" id="mon-per">По серверам</span></div>
<div className="mon-table-wrap">
<table className="mon-table">
<thead>
<tr><th>Сервер</th><th>Загрузка</th><th>Отдача</th><th>Онлайн</th><th>Процессор</th><th>Память</th><th>Потрачено</th></tr>
</thead>
<tbody>
{ov.servers.map(r => (
<tr key={r.id}>
<td>{r.name}{!r.ok && <span className="mon-tile-sub"> · нет метрик</span>}</td>
<td>{r.down != null ? rate(r.down) : '—'}</td>
<td>{r.up != null ? rate(r.up) : '—'}</td>
<td>{r.online != null ? nf(r.online, 0) : '—'}</td>
<td>{r.cpu != null ? `${nf(r.cpu, 0)} %` : '—'}</td>
<td>{r.mem != null ? `${nf(r.mem, 0)} %` : '—'}</td>
<td>{r.used_down != null && r.used_up != null ? vol(r.used_down + r.used_up) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{ov && data && (
<div className="mon-charts">
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => rate(v)} formatAxis={v => nf(v / unit.div, unit.div > 1 && v / unit.div < 10 ? 1 : 0)}
series={[
{ id: 'down', name: 'загрузка', color: 'var(--series-1)', points: data.down },
{ id: 'up', name: 'отдача', color: 'var(--series-2)', points: data.up },
]} />
<LineChart title="Процессор и память, %" from={ov.from} to={ov.to} step={ov.step} dim={loading} max={100}
format={v => `${nf(v, 0)} %`}
series={[
{ id: 'cpu', name: 'процессор', color: 'var(--series-1)', points: data.cpu },
{ id: 'mem', name: 'память', color: 'var(--series-2)', points: data.mem },
]} />
<LineChart title="Устройства" from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => nf(v, 0)}
series={[
{ id: 'online', name: 'онлайн', color: 'var(--series-1)', points: data.online },
{ id: 'heavy', name: 'в фоне', color: 'var(--series-2)', points: data.heavy },
]} />
<LineChart title="Отброшено очередью, пакетов/с" from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => nf(v, 1)}
series={[{ id: 'drops', name: 'отброшено', color: 'var(--series-1)', points: data.drops }]} />
</div>
)}
</>
);
return (
<>
{!status && <p className="mon-hint">Загрузка…</p>}
{setup}
{dashboard}
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "monitor",
"label": "Монитор",
"icon": "pulse",
"group": "server",
"order": 1.7,
"requires": "prometheus",
"chrome": {
"serverBar": false,
"restart": false
}
}
+126
View File
@@ -0,0 +1,126 @@
/*
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. */
.mon-card {
display: flex; flex-direction: column; gap: 12px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--shape-extra-large);
padding: 20px;
margin-bottom: 16px;
}
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.mon-title { margin: 0; font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@media (max-width: 599px) {
.mon-actions .btn { flex: 1; }
.mon-toolbar .server-select { flex: 1 1 100%; } /* своя строка под периодом */
.mon-toolbar .server-current { width: 100%; }
.mon-toolbar .server-menu { width: 100%; }
}
/* ───── Графики ─────
Цвета рядов — токены темы. Проверено validate_palette: светлая #6a45b8 / #c4620a на #fdfaf6,
тёмная #8f6be0 / #d9772a на #2b251d (CVD ΔE ≥ 19, контраст ≥ 3:1). Подписи и значения цветом ряда не красим. */
:root { --series-1: #6a45b8; --series-2: #c4620a; }
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
/* Строка над плитками: период слева, выбор сервера (components/ServerSelect) справа; на узком — друг под другом */
.mon-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; margin-bottom: 16px; }
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; }
.mon-range {
min-height: 44px; padding: 0 18px;
font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--shape-full);
cursor: pointer;
}
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: var(--weight-medium); }
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
.mon-tile {
display: flex; flex-direction: column; gap: 4px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--shape-large);
padding: 14px 16px;
}
.mon-tile-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-tile-value { font-size: var(--title-large-size); line-height: var(--title-large-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-tile-sub { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-tile-rates { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
.mon-tile-rate { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; white-space: nowrap; }
.mon-tile-dir { font-size: var(--body-large-size); line-height: var(--body-large-line); }
.mon-tile-total { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--outline-variant); }
.mon-tile-meter { display: flex; flex-direction: column; gap: 4px; }
.mon-tile-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
.mon-charts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 16px; }
@media (min-width: 1000px) {
.mon-charts { grid-template-columns: 1fr 1fr; }
.mon-charts .mon-chart:first-child { grid-column: 1 / -1; }
}
.mon-chart {
margin: 0; min-width: 0;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--shape-extra-large);
padding: 16px;
transition: opacity var(--duration-short3) var(--ease-standard);
}
.mon-chart--dim { opacity: .6; }
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
.mon-chart-title { font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-legend-item { display: inline-flex; align-items: center; gap: 6px; }
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: var(--shape-extra-small); flex: none; }
.mon-chart-toggle {
margin-left: auto; min-height: 44px; padding: 0 12px;
font: inherit; font-size: var(--label-large-size); line-height: var(--label-large-line); color: var(--on-primary-container);
background: none; border: 0; border-radius: var(--shape-full); cursor: pointer;
}
.mon-plot { position: relative; }
.mon-plot svg { display: block; max-width: 100%; }
.mon-plot svg:focus-visible { border-radius: var(--shape-small); }
.mon-grid { stroke: var(--outline-variant); stroke-width: 1; }
.mon-tick { fill: var(--neutral-text); font-size: var(--body-small-size); font-family: var(--font); }
.mon-cross { stroke: var(--outline); stroke-width: 1; }
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); }
.mon-tip {
position: absolute; pointer-events: none; z-index: 2;
display: flex; flex-direction: column; gap: 4px;
background: var(--surface-container);
border: 1px solid var(--outline-variant); border-radius: var(--shape-medium);
padding: 8px 10px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
}
.mon-tip-time { color: var(--neutral-text); }
.mon-tip-row { display: flex; align-items: center; gap: 8px; }
.mon-tip-row b { font-weight: var(--weight-medium); font-size: var(--label-large-size); line-height: var(--label-large-line); color: var(--on-surface); }
.mon-table-wrap { overflow-x: auto; }
.mon-table { width: 100%; border-collapse: collapse; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.mon-table th { text-align: left; font-weight: var(--weight-medium); color: var(--on-surface-variant); padding: 8px 10px; white-space: nowrap; }
.mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mon-notice { margin-bottom: 12px; }
/* Полоски процессора и памяти в плитках */
.mon-bar { display: block; width: 100%; height: 8px; border-radius: var(--shape-extra-small); overflow: hidden; background: var(--primary-container); margin: 2px 0; }
.mon-bar i { display: block; height: 100%; border-radius: var(--shape-extra-small); background: var(--series-1); transition: width var(--duration-medium2) var(--ease-standard); }
.mon-bar i.mon-bar--warn { background: var(--warn-dot); }
.mon-bar i.mon-bar--bad { background: var(--error-dot); }
/* Разбивка «По серверам» в режиме «Все серверы» */
.mon-per-server { margin-bottom: 16px; }
/* Кнопка рядом с подписью — по центру строки */
.mon-actions { align-items: center; }
@@ -0,0 +1,97 @@
/*
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.
*/
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
/* Открытый API-ключ (показывается один раз при создании) */
.apikey-value {
font-family: var(--mono);
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-surface);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: var(--shape-medium);
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-empty {
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
}
.apikey-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--shape-extra-large);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.apikey-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.apikey-card-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.apikey-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label {
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: var(--weight-medium);
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apikey-card-prefix {
font-family: var(--mono);
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text);
word-break: break-all;
}
.apikey-card-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: var(--body-small-size); line-height: var(--body-small-line);
color: var(--neutral-text);
}
/* compact и medium (< 840px): карточки вместо таблицы, на планшете — сеткой */
@media (max-width: 839px) {
.apikey-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
/* счётчик и «пусто» — на всю строку сетки */
:is(.apikey-count, .apikey-empty) { grid-column: 1 / -1; }
}
/* Настройки ключа: какие серверы он видит */
.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: var(--shape-large);
padding: 12px 16px 14px;
margin: 6px 0 8px;
}
.apikey-tools-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
.apikey-tools-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
.apikey-card-servers { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; }
+207
View File
@@ -0,0 +1,207 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../../components/icons';
import ServerPicker, { serverName } from '../../../components/ServerPicker';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Раздел вкладки «Настройки».
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
// управляет мастер-ключами. По умолчанию новый ключ видит выбранный в панели сервер, набор
// правится в настройках ключа.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в панели
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch {
showMsg('Ошибка загрузки ключей');
}
}, [token, showMsg]);
const setServers = useCallback(async (k: ApiKey, next: number[], n: NodeInfo, added: boolean) => {
try {
await apiFetch('PATCH', `/ui/apikeys/${k.id}`, token, { servers: next });
showMsg(added ? `Ключ видит «${serverName(nodes, n.id)}»` : `«${serverName(nodes, n.id)}» убран из ключа`);
await load();
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
}
}, [token, nodes, load, showMsg]);
const serversText = (k: ApiKey) => (k.servers ?? [k.server_id]).map(id => serverName(nodes, id)).join(', ');
const toolbox = (k: ApiKey) => (
<section className="apikey-tools">
<h4 className="apikey-tools-title">Серверы ключа</h4>
<p className="apikey-tools-hint">
На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой
выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.
</p>
<ServerPicker nodes={nodes} selected={k.servers ?? [k.server_id]} onChange={(next, n, added) => setServers(k, next, n, added)} />
</section>
);
const createKey = useCallback(async () => {
if (!label.trim()) return;
const def = getServerId() ?? 0;
const servers = picked ?? [def];
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: servers.includes(def) ? def : servers[0], servers });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setPicked(null);
setCreated({ label: r.label, key: r.key });
await load();
}, [label, picked, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
showMsg('Ключ удалён: ' + lbl);
await load();
}, [token, load, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
return (
<>
<div className="toolbar">
<input
className="field"
placeholder="Метка ключа (напр. ci-bot)"
value={label}
onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createKey()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createKey}>
<IcoPlus /> Создать ключ
</button>
</div>
</div>
<div className="apikey-new-servers">
<span className="apikey-tools-hint">Серверы нового ключа:</span>
<ServerPicker nodes={nodes} selected={picked ?? [getServerId() ?? 0]}
onChange={next => setPicked(next)} />
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Серверы</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={7} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<Fragment key={k.id}>
<tr>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{serversText(k)}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon"
title="Серверы ключа"
aria-label="Серверы ключа"
aria-expanded={tools === k.id}
onClick={() => setTools(tools === k.id ? null : k.id)}
><IcoSettings /></button>
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
</Fragment>
))}
</tbody>
</table>
</div>
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
<div className="apikey-cards-list">
<p className="apikey-count">{keys.length} ключей</p>
{keys.length === 0 ? (
<p className="apikey-empty">Нет ключей</p>
) : keys.map((k, i) => (
<div className="apikey-card" key={k.id}>
<div className="apikey-card-header">
<div className="apikey-card-title">
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</div>
<div className="apikey-card-btns">
<button
className="btn btn--tonal btn--sq"
aria-label="Серверы ключа"
aria-expanded={tools === k.id}
onClick={() => setTools(tools === k.id ? null : k.id)}
><IcoSettings /></button>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<span className="apikey-card-servers">Серверы: {serversText(k)}</span>
{tools === k.id && toolbox(k)}
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
</div>
</div>
))}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">Ключ «{created.label}» создан</p>
<p className="qr-hint">
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
</div>
</div>
</div>
)}
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
/*
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. */
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.au-run { margin-left: auto; }
.au-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.au-notice {
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
border-radius: var(--shape-large); padding: 10px 16px; margin: 0;
}
.au-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; }
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.au-table td, .au-table th { white-space: nowrap; }
/* мобильный: таблица скрыта (.table-card), то же — карточками */
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.au-card .au-pub { max-width: 100%; }
@media (max-width: 839px) { .au-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } }
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
.au-run { margin-left: 0; }
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.au-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.au-setting-name { font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
.au-max { width: 88px !important; }
.au-select { width: auto !important; }
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.au-log .au-pub { max-width: 240px; }
+193
View File
@@ -0,0 +1,193 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../../lib/shared';
import { IcoRefresh, IcoTrash } from '../../../components/icons';
import { serverName } from '../../../components/ServerPicker';
import './audit.css';
// Раздел «Аудит» вкладки «Настройки»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
interface AuditEntry {
server_id: number; status: 'ok' | 'offline' | 'error'; ghosts_checked: boolean;
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number; blocked?: number;
}
interface Settings { clean: boolean; every_h: number; max: number }
interface Cleaned { at: number; server_id: number; pub_key: string; kind: 'ghost' | 'orphan'; key?: string | null; auto: boolean }
interface AuditData { enabled: boolean; settings: Settings; report: { at: number; servers: AuditEntry[] } | null; cleaned: Cleaned[] }
const PERIODS = [1, 3, 6, 12, 24];
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function AuditPage({ token, showMsg }: PageProps) {
const [audit, setAudit] = useState<AuditData | null>(null);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [busy, setBusy] = useState(false);
const [maxEdit, setMaxEdit] = useState('');
const load = useCallback(async () => {
try {
setAudit(await apiFetch('GET', '/ui/masterkeys/audit', token));
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch { showMsg('Ошибка загрузки аудита'); }
}, [token, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
const call = useCallback(async (method: string, path: string, ok: string, body?: object) => {
setBusy(true);
try { setAudit(await apiFetch(method, `/ui/masterkeys/audit${path}`, token, body)); showMsg(ok); }
catch (e) { showMsg(errText(e)); }
finally { setBusy(false); }
}, [token, showMsg]);
const run = () => call('POST', '', 'Аудит выполнен');
const save = (patch: Partial<Settings>) => call('PUT', '/settings', 'Настройки сохранены', patch);
if (!audit) return null;
if (!audit.enabled) return <p className="au-empty">Аудит выключен на этой панели: его проводит только core.</p>;
const { settings } = audit;
const servers = audit.report?.servers ?? [];
const dead = servers.reduce((n, e) => n + e.ghosts.length + e.orphans.length, 0);
const bad = servers.filter(e => e.status !== 'ok');
const confirmed = servers.reduce((n, e) => n + [...e.ghosts, ...e.orphans].filter(p => p.seen_before).length, 0);
const blocked = servers.filter(e => e.blocked);
const rows = servers.flatMap(e => [
...e.ghosts.map(p => ({ e, p, why: 'Есть в ядре, у awg-ctrl записи нет' })),
...e.orphans.map(p => ({ e, p, why: 'Панели не нужен, не снят' })),
]);
const state = (p: AuditPeer) => (
<span className={`chip chip--${p.online ? 'error' : 'offline'}`}>
<span className="chip-dot" />{p.online ? 'Онлайн' : p.lastHandshake ? `был ${timeAgo(p.lastHandshake)} назад` : 'не подключался'}
</span>
);
const confirm = (p: AuditPeer) => (
<span className="au-hint">{p.seen_before ? 'подтверждён' : 'найден впервые — снимется, если останется и в следующий раз'}</span>
);
return (
<>
<section className="au-head">
<span className={`chip chip--${!audit.report ? 'offline' : dead ? 'error' : 'online'}`}>
<span className="chip-dot" />
{!audit.report ? 'Аудит ещё не проводился' : dead ? `Мёртвых пиров: ${dead}` : 'Мёртвых пиров нет'}
</span>
{audit.report && (
<span className="au-hint">
Проверено {timeAgo(audit.report.at)} назад · серверов: {servers.length} · раз в {settings.every_h} ч
</span>
)}
<div className="au-btns">
{confirmed > 0 && (
<button className="btn btn--tonal" disabled={busy}
onClick={() => window.confirm(`Снять с серверов подтверждённых пиров: ${confirmed}?`) && call('POST', '/clean', 'Очистка выполнена')}>
<IcoTrash /> Снять подтверждённых ({confirmed})
</button>
)}
<button className="btn btn--tonal" disabled={busy} onClick={run}>
<IcoRefresh /> {busy ? 'Работаем…' : 'Проверить сейчас'}
</button>
</div>
</section>
<section className="au-box">
<h4 className="au-box-title">Параметры аудита</h4>
<label className="au-setting">
<span className="au-setting-text">
<span className="au-setting-name">Снимать мёртвых автоматически</span>
<span className="au-hint">Только тех, кто мёртв два прохода подряд. Ручной проход ничего не снимает.</span>
</span>
<input type="checkbox" checked={settings.clean} disabled={busy} onChange={e => save({ clean: e.target.checked })} />
</label>
<div className="au-setting">
<span className="au-setting-text">
<label className="au-setting-name" htmlFor="au-every">Период проверки</label>
<span className="au-hint">Как часто core обходит серверы</span>
</span>
<select id="au-every" className="field au-select" value={settings.every_h} disabled={busy}
onChange={e => save({ every_h: Number(e.target.value) })}>
{[...new Set([...PERIODS, settings.every_h])].sort((a, b) => a - b).map(h => <option key={h} value={h}>{h} ч</option>)}
</select>
</div>
<div className="au-setting">
<span className="au-setting-text">
<label className="au-setting-name" htmlFor="au-max">Предел за проход</label>
<span className="au-hint">Если на сервере мёртвых больше — автоочистка ничего не снимает и только сообщает (защита от сбоя)</span>
</span>
<span className="au-setting-ctl">
<input id="au-max" className="field au-max" type="number" min={1} max={1000}
value={maxEdit || String(settings.max)} onChange={e => setMaxEdit(e.target.value)} />
<button className="btn btn--tonal" disabled={busy || !maxEdit || Number(maxEdit) === settings.max}
onClick={() => save({ max: Number(maxEdit) }).then(() => setMaxEdit(''))}>Сохранить</button>
</span>
</div>
</section>
{bad.length > 0 && (
<p className="au-notice">
Не проверены: {bad.map(e => `${serverName(nodes, e.server_id)} (${e.status === 'offline' ? 'не в сети' : 'ошибка'})`).join(', ')}
</p>
)}
{blocked.length > 0 && (
<p className="au-notice">
Автоочистка пропустила: {blocked.map(e => `${serverName(nodes, e.server_id)} (${e.blocked} пиров при пределе ${settings.max})`).join(', ')}. Проверьте список и снимите вручную.
</p>
)}
{servers.some(e => e.status === 'ok' && !e.ghosts_checked) && (
<p className="au-notice">Часть серверов на старой версии awg-ctrl: пиров, которые есть только в ядре, там не видно.</p>
)}
{rows.length > 0 && (
<>
<div className="table-card au-table-card">
<table>
<thead><tr><th>Сервер</th><th>Публичный ключ</th><th>Причина</th><th>Мастер-ключ</th><th>Состояние</th></tr></thead>
<tbody>
{rows.map(({ e, p, why }) => (
<tr key={`${e.server_id}:${p.pub_key}`}>
<td>{serverName(nodes, e.server_id)}</td>
<td className="td-mono"><code className="au-pub" title={p.pub_key}>{p.pub_key}</code></td>
<td>{why}</td>
<td>{p.key ?? '—'}</td>
<td>{state(p)}<br />{confirm(p)}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="au-cards">
{rows.map(({ e, p, why }) => (
<li className="au-card" key={`${e.server_id}:${p.pub_key}`}>
<div className="au-card-top"><b>{serverName(nodes, e.server_id)}</b>{state(p)}</div>
<code className="au-pub" title={p.pub_key}>{p.pub_key}</code>
<span className="au-hint">{why}{p.key ? ` · ${p.key}` : ''}</span>
{confirm(p)}
</li>
))}
</ul>
</>
)}
{audit.cleaned.length > 0 && (
<section className="au-box">
<h4 className="au-box-title">Снято</h4>
<ul className="au-log">
{audit.cleaned.slice(0, 20).map(c => (
<li key={`${c.at}:${c.pub_key}`}>
<span className="au-hint">{timeAgo(c.at)} назад</span>
<span>{serverName(nodes, c.server_id)}</span>
<code className="au-pub" title={c.pub_key}>{c.pub_key}</code>
<span className="au-hint">{c.kind === 'ghost' ? 'был только в ядре' : 'лишний у awg-ctrl'}{c.key ? ` · ${c.key}` : ''} · {c.auto ? 'автоматически' : 'вручную'}</span>
</li>
))}
</ul>
</section>
)}
{audit.report && rows.length === 0 && <p className="au-empty">Всё чисто: лишних пиров на серверах нет.</p>}
</>
);
}
+304
View File
@@ -0,0 +1,304 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { apiFetch, copyText, timeAgo, BRAND_FALLBACK, SETTINGS_SECTION_KEY, type Brand, type NodeInfo, type PageProps } from '../../lib/shared';
import { useTheme, setThemeMode, type ThemeMode } from '../../lib/theme';
import { IcoCheck, IcoCopy, IcoRefresh, IcoTrash, IcoPlus } from '../../components/icons';
import ApiKeysPage from './apikeys';
import AuditPage from './audit';
import MigratePage from './migrate';
import PrometheusInstall from './prometheus';
import ServerSettings from './server';
import './settings.css';
// Вкладка «Настройки»: настройки самой панели, не конкретного сервера (те — в меню серверов).
// Разделы — по вкладкам MD3 (primary tabs) сверху, виден один:
// Оформление — тема (хранится в браузере, lib/theme).
// Сервер — деление канала и блокировка торрентов сервера из шапки (./server; туда ведёт и шестерёнка
// в меню серверов).
// API-ключи, Аудит, Перенос — бывшие отдельные вкладки меню; живут в подпапках и
// рендерятся здесь целиком (реестр tabs/index.ts их не видит — он ищет на уровень выше).
// Prometheus — установка встроенного Prometheus (./prometheus) и токен «только метрики» для GET /metrics (/ui/metrics-token): выпуск,
// перевыпуск, отзыв. Значение показывается один раз, в БД — только хэш.
// О панели — имя, канал, версия и серверы.
interface MetricsToken {
exists: boolean;
prefix?: string;
created_at?: number;
last_used?: number | null;
per_peer: boolean; // пер-пировые серии (METRICS_PEERS=on в .env)
}
const SECTIONS = [
{ id: 'look', label: 'Оформление' },
{ id: 'server', label: 'Сервер' },
{ id: 'apikeys', label: 'API-ключи' },
{ id: 'prom', label: 'Prometheus' },
{ id: 'audit', label: 'Аудит' },
{ id: 'migrate', label: 'Перенос' },
{ id: 'about', label: 'О панели' },
] as const;
type SectionId = (typeof SECTIONS)[number]['id'];
// Открытый раздел помнится в браузере — чтобы «Аудит» или «Перенос» не искать заново
// (туда же пишет openTab из lib/shared, когда другая вкладка ведёт в нужный раздел)
const indexOf = (id: SectionId) => SECTIONS.findIndex(t => t.id === id);
function savedSection(): SectionId {
try {
const v = localStorage.getItem(SETTINGS_SECTION_KEY);
if (SECTIONS.some(t => t.id === v)) return v as SectionId;
} catch { /* не критично */ }
return 'look';
}
// Фрагмент prometheus.yml для внешнего Prometheus. Адрес — от страницы: в нём уже секретный путь
// панели (UI_PREFIX) или подпапка nginx, поэтому конфиг верен в обоих случаях.
function scrapeConfig(tok: string): string {
const u = new URL('metrics', location.href.split(/[?#]/)[0]);
return [
'scrape_configs:',
' - job_name: vpn-panel',
` scheme: ${u.protocol.replace(':', '')}`,
` metrics_path: ${u.pathname}`,
' authorization:',
` credentials: ${tok}`,
' static_configs:',
` - targets: ['${u.host}']`,
].join('\n');
}
const THEMES: { id: ThemeMode; label: string }[] = [
{ id: 'light', label: 'Светлая' },
{ id: 'dark', label: 'Тёмная' },
{ id: 'system', label: 'Как в системе' },
];
export default function SettingsPage({ token, showMsg }: PageProps) {
const { mode } = useTheme();
const [metrics, setMetrics] = useState<MetricsToken | null>(null);
const [created, setCreated] = useState<string | null>(null); // открытый токен — один раз после выпуска
const [busy, setBusy] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [nodes, setNodes] = useState<NodeInfo[]>([]);
// dir — откуда въезжает раздел: 1 — справа (вкладка правее прежней), -1 — слева, 0 — первый показ
const [view, setView] = useState<{ section: SectionId; dir: number }>(() => ({ section: savedSection(), dir: 0 }));
const section = view.section;
const go = (id: SectionId) => setView(v => v.section === id ? v : { section: id, dir: Math.sign(indexOf(id) - indexOf(v.section)) });
const tabRefs = useRef<Partial<Record<SectionId, HTMLButtonElement | null>>>({});
const indicator = useRef<HTMLSpanElement>(null);
const centered = useRef(false); // первый показ — без анимации прокрутки вкладок
// Индикатор — одна полоса под активной вкладкой: переезжает к ней, а не появляется заново
useLayoutEffect(() => {
const place = () => {
const b = tabRefs.current[section], el = indicator.current;
if (b && el) { el.style.left = `${b.offsetLeft}px`; el.style.width = `${b.offsetWidth}px`; }
};
place();
// На узком экране вкладки прокручиваются — выбранную ставим по центру полосы (у краёв — насколько
// позволяет прокрутка). Двигаем саму полосу, а не scrollIntoView: тот прокрутил бы и страницу.
const b = tabRefs.current[section], bar = b?.parentElement;
if (b && bar && bar.scrollWidth > bar.clientWidth) {
const still = !centered.current || matchMedia('(prefers-reduced-motion: reduce)').matches;
bar.scrollTo({ left: b.offsetLeft - (bar.clientWidth - b.offsetWidth) / 2, behavior: still ? 'auto' : 'smooth' });
}
centered.current = true;
try { localStorage.setItem(SETTINGS_SECTION_KEY, section); } catch { /* не критично */ }
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
}, [section]);
// openTab('settings', раздел) при уже открытых настройках: раздел записан в localStorage — переключаемся на него
useEffect(() => {
const h = (e: Event) => { if ((e as CustomEvent).detail === 'settings') go(savedSection()); };
window.addEventListener('awg:open-tab', h);
return () => window.removeEventListener('awg:open-tab', h);
}, []);
// Стрелки, Home/End — по вкладкам (MD3 / WAI-ARIA tabs)
const onTabKey = (e: React.KeyboardEvent) => {
const i = indexOf(section);
const j = e.key === 'ArrowRight' ? (i + 1) % SECTIONS.length
: e.key === 'ArrowLeft' ? (i + SECTIONS.length - 1) % SECTIONS.length
: e.key === 'Home' ? 0 : e.key === 'End' ? SECTIONS.length - 1 : -1;
if (j < 0) return;
e.preventDefault();
go(SECTIONS[j].id);
tabRefs.current[SECTIONS[j].id]?.focus();
};
// Адрес /metrics рядом с панелью — с учётом префикса, под которым она открыта
const metricsUrl = new URL('metrics', location.href.split(/[?#]/)[0]).href;
const load = useCallback(async () => {
try {
setMetrics(await apiFetch('GET', '/ui/metrics-token', token));
} catch {
showMsg('Ошибка загрузки настроек');
}
axios.get('/ui/brand').then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK, channel: data.channel || '', version: data.version || '',
})).catch(() => {});
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
}, [token, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
const issue = useCallback(async () => {
if (metrics?.exists && !confirm('Перевыпустить токен? Старый сразу перестанет работать — Prometheus нужно будет перенастроить.')) return;
setBusy(true);
try {
const r = await apiFetch('POST', '/ui/metrics-token', token);
setCreated(r.token);
setMetrics(r);
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
} finally {
setBusy(false);
}
}, [metrics, token, showMsg]);
const revoke = useCallback(async () => {
if (!confirm('Отозвать токен? Prometheus перестанет получать метрики панели.')) return;
setBusy(true);
try {
setMetrics(await apiFetch('DELETE', '/ui/metrics-token', token));
showMsg('Токен отозван');
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
} finally {
setBusy(false);
}
}, [token, showMsg]);
const online = nodes.filter(n => n.online).length;
return (
<>
<div className="st-tabs" role="tablist" aria-label="Разделы настроек" onKeyDown={onTabKey}>
{SECTIONS.map(t => (
<button key={t.id} type="button" role="tab" id={`st-tab-${t.id}`} className="st-tab"
aria-selected={section === t.id} aria-controls={`st-panel-${t.id}`}
tabIndex={section === t.id ? 0 : -1}
ref={el => { tabRefs.current[t.id] = el; }} onClick={() => go(t.id)}>
{t.label}
</button>
))}
<span className="st-tabs-indicator" ref={indicator} aria-hidden="true" />
</div>
{/* Раздел пересоздаётся при смене (key) и въезжает со стороны выбранной вкладки */}
<div key={section} className="st-view" role="tabpanel" id={`st-panel-${section}`} aria-labelledby={`st-tab-${section}`}
style={{ '--st-dir': view.dir } as React.CSSProperties}>
{section === 'look' && (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Тема</span>
<span className="st-hint">Запоминается в этом браузере</span>
</span>
<div className="segmented" role="radiogroup" aria-label="Тема">
{THEMES.map(t => (
<button key={t.id} type="button" role="radio" aria-checked={mode === t.id}
className="segmented-item" onClick={() => setThemeMode(t.id)}>
{mode === t.id && <IcoCheck />}{t.label}
</button>
))}
</div>
</div>
</section>
)}
{section === 'prom' && <PrometheusInstall token={token} showMsg={showMsg} />}
{section === 'prom' && (
<section className="st-section">
<p className="st-hint st-lead">
Метрики панели и всех серверов отдаются по адресу <code className="st-code">{metricsUrl}</code> только
с токеном: Prometheus передаёт его заголовком <code className="st-code">Authorization: Bearer</code>.
Токен открывает только метрики — ни панель, ни API.
</p>
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Токен метрик</span>
{metrics?.exists ? (
<span className="st-hint">
<code className="st-code">{metrics.prefix}</code> · выпущен {new Date((metrics.created_at ?? 0) * 1000).toLocaleDateString()}
{' · '}{metrics.last_used ? `скрейп ${timeAgo(metrics.last_used)} назад` : 'ещё не использовался'}
</span>
) : (
<span className="st-hint">{metrics ? 'Не выпущен — /metrics закрыт' : 'Загрузка…'}</span>
)}
</span>
{metrics && (
<span className="st-row-ctl">
{metrics.exists && (
<button className="btn btn--danger" disabled={busy} onClick={revoke}><IcoTrash /> Отозвать</button>
)}
<button className={`btn ${metrics.exists ? 'btn--tonal' : 'btn--primary'}`} disabled={busy} onClick={issue}>
{metrics.exists ? <><IcoRefresh /> Перевыпустить</> : <><IcoPlus /> Выпустить</>}
</button>
</span>
)}
</div>
{metrics && (
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Метрики по пользователям</span>
<span className="st-hint">
{metrics.per_peer
? 'Включены: серии по каждому пиру с внутренним IP и именем'
: 'Выключены. Включаются строкой METRICS_PEERS=on в .env панели'}
</span>
</span>
<span className={`chip chip--${metrics.per_peer ? 'online' : 'offline'}`}>
<span className="chip-dot" />{metrics.per_peer ? 'Вкл' : 'Выкл'}
</span>
</div>
)}
</section>
)}
{section === 'server' && <ServerSettings token={token} showMsg={showMsg} />}
{section === 'apikeys' && <ApiKeysPage token={token} showMsg={showMsg} />}
{section === 'audit' && <AuditPage token={token} showMsg={showMsg} />}
{section === 'migrate' && <MigratePage token={token} showMsg={showMsg} />}
{section === 'about' && (
<section className="st-section">
<dl className="st-facts">
<div><dt>Панель</dt><dd>{brand.brand}</dd></div>
<div><dt>Версия</dt><dd>{[brand.channel, brand.version].filter(Boolean).join(' ') || '—'}</dd></div>
<div><dt>Серверы</dt><dd>{nodes.length ? `${nodes.length}, на связи ${online}` : '—'}</dd></div>
</dl>
</section>
)}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" role="dialog" aria-modal="true" aria-labelledby="st-new-title" onClick={e => e.stopPropagation()}>
<p className="qr-name" id="st-new-title">Токен метрик выпущен</p>
<p className="qr-hint">
Скопируй его сейчас — он показывается один раз. Готовый фрагмент для prometheus.yml:
</p>
<pre className="st-secret st-yml">{scrapeConfig(created)}</pre>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
<button className="btn btn--tonal" onClick={async () => {
await copyText(created);
showMsg('Токен скопирован');
}}><IcoCopy /> Токен</button>
<button className="btn btn--primary" onClick={async () => {
await copyText(scrapeConfig(created));
showMsg('Фрагмент prometheus.yml скопирован');
}}><IcoCopy /> Фрагмент</button>
</div>
</div>
</div>
)}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "settings",
"label": "Настройки",
"icon": "settings",
"group": "server",
"order": 9,
"chrome": {
"restart": false
}
}
+229
View File
@@ -0,0 +1,229 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, type PageProps } from '../../../lib/shared';
import { IcoCopy } from '../../../components/icons';
import './migrate.css';
// Раздел «Перенос» вкладки «Настройки»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
type MoveState = '' | 'sending' | 'moved' | 'uncertain' | 'imported';
// Новая панель после приёма: кто уже знает её адрес — тогда старый сервер можно выключать.
interface Retire {
since: number;
home: string | null; // адрес хаба для нод; null — не задан SUB_HOST
old: { exists: boolean; online: boolean };
devices: { total: number; fresh: number }; // fresh — получили конфиг после переноса
unused_links: number; // мастер-ключи без устройств: в ссылке старый адрес
nodes: { id: number; name: string; online: boolean; ok: boolean }[];
}
interface MigrateInfo {
state: MoveState;
error: string | null;
target: string | null; // старая: адрес новой панели
code: { code: string; expires_in: number } | null;
export?: string | null; // null — можно, строка — почему нельзя
accept?: string | null;
retire?: Retire | null;
}
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function MigratePage({ token, showMsg }: PageProps) {
const [info, setInfo] = useState<MigrateInfo | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
try { setInfo(await apiFetch('GET', '/ui/migrate', token)); }
catch { /* панель перезапускается — следующий опрос покажет */ }
}, [token]);
useEffect(() => { if (token) load(); }, [token, load]);
// Пока висит код — ждём, когда старая панель пришлёт архив; после приёма панель
// перезапускается, и страница перечитывается уже с перенесённой базой.
const waiting = !!info?.code || info?.state === 'imported' || !!info?.retire;
useEffect(() => {
if (!waiting) return;
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [waiting, load]);
useEffect(() => {
if (info?.state !== 'imported' || info.error) return;
const t = setTimeout(() => window.location.reload(), 8000);
return () => clearTimeout(t);
}, [info?.state, info?.error]);
const call = async (method: string, path: string, body?: object) => {
setBusy(true); setError('');
try { setInfo(await apiFetch(method, `/ui/migrate${path}`, token, body)); }
catch (e) { setError(errText(e)); }
finally { setBusy(false); }
};
const send = async () => {
if (!window.confirm('Перенести панель? Здесь она остановится, а этот сервер станет нодой новой панели.')) return;
setBusy(true); setError('');
try {
await apiFetch('POST', '/ui/migrate/export', token, { code: code.trim() });
await load();
} catch (e) { setError(errText(e)); await load(); }
finally { setBusy(false); }
};
if (!info) return null;
if (info.state === 'moved') return (
<section className="mv-box">
<span className="chip chip--online mv-self"><span className="chip-dot" />Панель перенесена</span>
<p className="mv-text">
Новая панель: <a href={info.target ?? '#'}>{info.target}</a>. Через несколько секунд этот сервер станет её
нодой #0 и будет пересылать туда прежние порты: ссылки sen://, ноды и внешний API продолжат работать,
а этот адрес откроет уже новую панель. Вход — логином и паролем новой панели.
</p>
{info.target && <a className="btn btn--primary mv-self" href={info.target}>Открыть новую панель</a>}
</section>
);
if (info.state === 'imported') return (
<section className="mv-box">
<span className={`chip chip--${info.error ? 'error' : 'online'} mv-self`}><span className="chip-dot" />
{info.error ? 'Приём прерван' : 'Панель принята'}</span>
<p className="mv-text">
{info.error ?? 'Панель перезапускается с перенесённой базой — страница обновится сама. Старый сервер появится среди серверов как «#0», когда подключится.'}
</p>
</section>
);
const r = info.retire;
const retireBox = r && (() => {
const devicesOk = r.devices.fresh >= r.devices.total;
const nodesOk = r.nodes.every(n => n.ok);
const ready = devicesOk && nodesOk && !!r.home;
const chip = (ok: boolean, text: string, offline = false) => (
<span className={`chip chip--${ok ? 'online' : offline ? 'offline' : 'warm'}`}><span className="chip-dot" />{text}</span>
);
return (
<section className="mv-box" key="retire">
<h4 className="mv-title">Отключение старого сервера</h4>
<p className="mv-text">
{r.old.exists
? <>Старый сервер — здесь это сервер «#0» — пока пересылает сюда запросы по старым адресам. Выключать его
можно, когда новый адрес панели узнают все клиенты и ноды: мастер-ключи при этом остаются прежними.</>
: <>Сервер «#0» удалён из панели. Если старая машина ещё работает, её можно выключать.</>}
</p>
<div className="mv-checks">
<div className="mv-check">
{chip(devicesOk, `${r.devices.fresh} из ${r.devices.total}`)}
<span>устройств получили новый адрес{!devicesOk && ' — остальные получат при следующем обновлении подписки'}</span>
</div>
{r.nodes.map(n => (
<div className="mv-check" key={n.id}>
{chip(n.ok, n.ok ? 'знает адрес' : n.online ? 'ещё нет' : 'не в сети', !n.ok && !n.online)}
<span>нода «{n.name}»{!n.ok && n.online && ' — обновите на ней Forgetting'}</span>
</div>
))}
{r.unused_links > 0 && (
<div className="mv-check">
{chip(false, String(r.unused_links), true)}
<span>мастер-ключей ещё ни разу не открыты: в выданных ссылках старый адрес. Скопируйте их ссылки на
вкладке «Мастер-ключи» заново — новые уже с адресом этой панели, ключ тот же</span>
</div>
)}
</div>
{!r.home && <p className="mv-notice">Не задан SUB_HOST — ноды не узнают адрес панели. Задайте его в cli.env и перезапустите панель.</p>}
<ol className="mv-steps">
<li>Новый VPN-сервер: «Добавить сервер», затем отметьте его в мастер-ключах — клиенты получат его в конфиге сами.</li>
<li>Дождитесь, пока всё выше станет зелёным{ready && ' — уже'}.</li>
<li>Удалите сервер «#0» и выключите старую машину.</li>
</ol>
{!r.old.exists && (
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('DELETE', '/retire')}>Готово, скрыть</button>
)}
</section>
);
})();
const canExport = info.export === null, canAccept = info.accept === null;
const exportBox = (
<section className="mv-box" key="export">
<h4 className="mv-title">Перенести эту панель на другой сервер</h4>
{canExport ? (
<>
<p className="mv-text">
Панель уедет на отдельный сервер без VPN, а этот сервер останется VPN — нодой новой панели.
Пользователям ничего менять не нужно: ссылки sen:// и vpn://, ключи и конфиги остаются прежними.
</p>
<ol className="mv-steps">
<li>На новом сервере установите Forgetting с ролью «только панель».</li>
<li>Там в «Настройках», на вкладке «Перенос», нажмите «Получить код».</li>
<li>Вставьте код сюда и нажмите «Перенести».</li>
</ol>
{info.state === 'uncertain' ? (
<>
<p className="mv-notice">{info.error}</p>
<button className="btn btn--outline mv-self" disabled={busy} onClick={() => call('POST', '/cancel')}>
Отменить перенос
</button>
</>
) : (
<div className="mv-row">
<input className="field" placeholder="awgmove://…" value={code} disabled={busy}
onChange={e => setCode(e.target.value)} />
<button className="btn btn--primary" disabled={busy || !code.trim().startsWith('awgmove://')} onClick={send}>
{busy ? 'Переносим…' : 'Перенести'}
</button>
</div>
)}
</>
) : <p className="mv-text mv-muted">{info.export}</p>}
</section>
);
const acceptBox = (
<section className="mv-box" key="accept">
<h4 className="mv-title">Принять панель с другого сервера</h4>
{canAccept ? (
info.code ? (
<>
<p className="mv-text">
Вставьте код в «Настройках» старой панели, на вкладке «Перенос». Он одноразовый и действует
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
</p>
<code className="node-modal-cmd">{info.code.code}</code>
<div className="mv-row mv-row--end">
<button className="btn btn--tonal" onClick={() => copyText(info.code!.code).then(() => showMsg('Код скопирован'))}>
<IcoCopy /> Скопировать
</button>
<button className="btn btn--outline" disabled={busy} onClick={() => call('DELETE', '/code')}>Отозвать</button>
</div>
<p className="mv-text mv-muted">Ждём архив от старой панели…</p>
</>
) : (
<>
<p className="mv-text">
Эта панель примет базу, ключи подписки и серверы старой, а старый сервер подключится к ней
нодой #0. Логин и пароль останутся этой панели.
</p>
<button className="btn btn--primary mv-self" disabled={busy}
onClick={() => call('POST', '/code', { host: window.location.hostname })}>
Получить код
</button>
</>
)
) : <p className="mv-text mv-muted">{info.accept}</p>}
</section>
);
return (
<>
{/* Доступное действие — первым. */}
{retireBox}
{canAccept && !canExport ? [acceptBox, exportBox] : [exportBox, acceptBox]}
{error && <p className="mv-notice">{error}</p>}
</>
);
}
@@ -0,0 +1,25 @@
/*
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. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.mv-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
.mv-text { margin: 0; font-size: var(--body-medium-size); line-height: 1.5; }
.mv-muted { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: var(--body-medium-size); line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
border-radius: var(--shape-large); padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 599px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: var(--body-medium-size); line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
@@ -0,0 +1,141 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch, copyText, getServerId, announcePrometheus, type PageProps } from '../../../lib/shared';
import { IcoCopy, IcoPlus, IcoTrash } from '../../../components/icons';
import './prometheus.css';
// Установка встроенного Prometheus (раздел «Prometheus» вкладки «Настройки»; графики — во вкладке «Монитор»).
// Кнопка просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root; сама панель пакетов
// не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента — на сервер,
// выбранный в шапке (при смене сервера вкладка перемонтируется). У панели со своим VPN цель всегда
// «этот сервер».
export interface MonitorStatus {
ready: boolean; can_install: boolean; command: string;
host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
}
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
function JobProgress({ job, starting }: { job: MonitorStatus['job']; starting: boolean }) {
let step: [number, number, string] | null = null;
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
const failed = !!job && job.exit !== null && job.exit !== 0;
const done = !!job && job.exit === 0;
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
const label = failed ? `Не удалось (код ${job!.exit})`
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
return (
<div className="prom-progress" aria-live="polite">
<span className={`prom-pbar${failed ? ' prom-pbar--bad' : done ? ' prom-pbar--ok' : !step && (starting || job?.running) ? ' prom-pbar--wait' : ''}`}
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
<i style={{ width: `${pct}%` }} />
</span>
<span className="st-hint">{label}</span>
</div>
);
}
export default function PrometheusInstall({ token, showMsg }: PageProps) {
const target = getServerId() ?? 0; // куда ставить (панель без VPN) — сервер из шапки
const [status, setStatus] = useState<MonitorStatus | null>(null);
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена отсюда
const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', `/ui/monitor?server=${target}`, token) as MonitorStatus; announcePrometheus(s.ready); if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token, target]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог
useEffect(() => {
if (!pending && !status?.job?.running) return;
const t = setInterval(async () => {
const s = await loadStatus();
if (s && !s.job?.running) setPending(null);
}, 2000);
return () => clearInterval(t);
}, [pending, status?.job?.running, loadStatus]);
const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}?server=${target}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
if (!status) return <section className="st-section"><p className="st-hint">Загрузка…</p></section>;
const job = status.job;
const working = !!pending || !!job?.running;
const doing = pending ?? (job?.running ? job.action : null);
const showJob = !!pending || (!!job && (job.running || job.exit !== null));
const failed = !!job && job.exit !== null && job.exit !== 0;
// «Этот сервер» путал: в шапке может быть выбрана нода, а Prometheus у панели один и стоит не там
const where = status.host ? <>на сервере <b>{status.host.name}</b></> : 'на сервере панели';
const command = status.ready ? 'awg-ctrl monitor remove' : status.command;
return (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Встроенный Prometheus</span>
<span className="st-hint">
{status.ready
? <>Один на всю панель, собирает метрики всех серверов. Стоит {where}{status.host && ' (панель читает его через агента ноды)'}, слушает только 127.0.0.1:9090, графики хранит 31 день.</>
: <>Один на всю панель, соберёт метрики всех серверов. Панель поставит его {where} и сама подключит к своим метрикам: графики «Монитора» появятся через минуту-две. Наружу он не открывается.</>}
</span>
</span>
<span className="st-row-ctl">
<span className={`chip chip--${status.ready ? 'online' : 'offline'}`}>
<span className="chip-dot" />{doing === 'install' ? 'ставится' : doing === 'remove' ? 'удаляется' : status.ready ? 'работает' : 'не подключён'}
</span>
{status.can_install && (status.ready ? (
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
<IcoTrash /> {doing === 'remove' ? 'Удаляем…' : 'Удалить'}
</button>
) : (
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
<IcoPlus /> {doing === 'install' ? 'Устанавливаем…' : 'Установить'}
</button>
))}
</span>
</div>
{showJob && <JobProgress job={job} starting={!!pending} />}
{!status.can_install && (
<div className="st-row">
<span className="st-row-text">
<span className="st-hint">
{status.ready ? 'Удаляется на сервере с Prometheus от root:'
: 'На выбранном сервере установка кнопкой недоступна (нет связи или старый awg-ctrl): выберите другой сервер в шапке или выполните на этом от root:'}
</span>
<code className="prom-code">{command}</code>
</span>
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
</div>
)}
{/* Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt */}
{failed && (
<>
<pre className="prom-log">{job!.log.trim()}</pre>
<p className="login-error">
{job!.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${job!.action}`}</b>
</p>
</>
)}
</section>
);
}
@@ -0,0 +1,31 @@
/*
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.
*/
/* Установка Prometheus (раздел «Prometheus» вкладки «Настройки»). Строки — из settings.css. */
/* Ход установки или удаления: полоска + подпись шага */
.prom-progress { display: flex; flex-direction: column; gap: 6px; padding: 4px 0 12px; }
.prom-pbar { display: block; width: 100%; height: 8px; border-radius: var(--shape-extra-small); overflow: hidden; background: var(--primary-container); }
.prom-pbar i { display: block; height: 100%; border-radius: var(--shape-extra-small); background: var(--primary); transition: width var(--duration-medium4) var(--ease-standard); }
.prom-pbar--ok i { background: var(--success-dot); }
.prom-pbar--bad i { background: var(--error-dot); }
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
.prom-pbar--wait i { width: 35% !important; animation: prom-wait 1.2s ease-in-out infinite; }
@keyframes prom-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
@media (prefers-reduced-motion: reduce) { .prom-pbar--wait i { animation: none; } .prom-pbar i { transition: none; } }
.prom-code {
display: block; margin-top: 6px; word-break: break-all; user-select: all;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
background: var(--surface-container-lowest);
border-radius: var(--shape-medium);
padding: 10px 12px;
}
.prom-log {
margin: 0 0 8px; max-height: 240px; overflow: auto;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
background: var(--surface-container-lowest); border-radius: var(--shape-medium);
padding: 12px; white-space: pre-wrap; word-break: break-word;
}
+228
View File
@@ -0,0 +1,228 @@
// 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, type PageProps, type ShapingStatus, type TorrentStatus } from '../../../lib/shared';
import './server.css';
// Раздел «Сервер» вкладки «Настройки»: деление канала между устройствами (CAKE) и блокировка BitTorrent
// для сервера из шапки (при его смене вкладка перемонтируется). Сами настройки живут в awg-ctrl этого
// сервера — запрос идёт обычным прокси с X-Server-Id, так что ядро и ноды настраиваются одинаково.
export default function ServerSettings({ token, showMsg }: PageProps) {
const [st, setSt] = useState<ShapingStatus | null>(null);
const [enabled, setEnabled] = useState(false);
const [auto, setAuto] = useState(true);
const [down, setDown] = useState('');
const [up, setUp] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [unsupported, setUnsupported] = useState(false);
const [tst, setTst] = useState<TorrentStatus | null>(null);
const [tEnabled, setTEnabled] = useState(false);
const [tUnsupported, setTUnsupported] = useState(false);
const fill = (s: ShapingStatus) => {
setSt(s);
setEnabled(s.settings.enabled); setAuto(s.settings.auto);
setDown(s.settings.downMbit ? String(s.settings.downMbit) : '');
setUp(s.settings.upMbit ? String(s.settings.upMbit) : '');
};
const errText = (e: unknown, fallback: string) =>
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
const fillTorrent = (s: TorrentStatus) => { setTst(s); setTEnabled(s.settings.enabled); };
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/torrent', token)
.then(s => { if (alive) fillTorrent(s as TorrentStatus); })
.catch(e => {
if (!alive) return;
if (axios.isAxiosError(e) && e.response?.status === 404) setTUnsupported(true);
else setError(errText(e, 'Не удалось загрузить настройки'));
});
return () => { alive = false; };
}, [token]);
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/shaping', token)
.then(s => { if (alive) fill(s as ShapingStatus); })
.catch(e => {
if (!alive) return;
// Старый awg-ctrl не знает этот маршрут — его надо обновить, а не «починить» здесь.
if (axios.isAxiosError(e) && e.response?.status === 404) setUnsupported(true);
else setError(errText(e, 'Не удалось загрузить настройки'));
});
return () => { alive = false; };
}, [token]);
const downN = Number(down), upN = Number(up);
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
// Отправляем только изменённое: лишний PUT /api/shaping заново ставил бы tc на интерфейс.
const shapingDirty = !!st && (enabled !== st.settings.enabled || auto !== st.settings.auto
|| (Number(down) || 0) !== st.settings.downMbit || (Number(up) || 0) !== st.settings.upMbit);
const torrentDirty = !!tst && tEnabled !== tst.settings.enabled;
const save = async () => {
setBusy(true); setError('');
const done: string[] = [];
try {
if (shapingDirty) {
const s = await apiFetch('PUT', '/api/shaping', token, {
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
});
fill(s as ShapingStatus);
done.push(enabled ? 'деление канала включено' : 'деление канала выключено');
}
if (torrentDirty) {
const s = await apiFetch('PUT', '/api/torrent', token, { enabled: tEnabled });
fillTorrent(s as TorrentStatus);
done.push(tEnabled ? 'торренты блокируются' : 'блокировка торрентов выключена');
}
if (done.length) showMsg(done.join(', ').replace(/^./, c => c.toUpperCase()));
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
finally { setBusy(false); }
};
return (
<>
<section className="st-section ss-card">
<h3 className="ss-section">Деление канала</h3>
{unsupported ? (
<p className="st-hint">
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
</p>
) : (
<>
<p className="st-hint">
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
</p>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Честное деление между устройствами</span>
</span>
<input type="checkbox" checked={enabled} disabled={!st || busy} onChange={e => setEnabled(e.target.checked)} />
</label>
<div className="ss-grid">
<label className="ss-field">
<span className="ss-setting-name">Загрузка, Мбит/с</span>
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
value={down} disabled={!st || busy} onChange={e => setDown(e.target.value)} />
</label>
<label className="ss-field">
<span className="ss-setting-name">Отдача, Мбит/с</span>
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
</label>
</div>
<p className="st-hint">
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
и деление перестаёт работать.
</p>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
<span className="st-hint">
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
</span>
</span>
<input type="checkbox" checked={auto} disabled={!st || busy || !enabled} onChange={e => setAuto(e.target.checked)} />
</label>
{st?.settings.enabled && (
<div className="ss-status">
<span className={`chip ${st.active ? (st.congested ? 'chip--warm' : 'chip--online') : 'chip--offline'}`}>
<span className="chip-dot" />
{!st.active ? 'не применено' : st.congested ? 'канал занят' : 'канал свободен'}
</span>
<span className="ss-mono">
↓ {st.rateDown} · ↑ {st.rateUp} Мбит/с · устройств {st.devices}
</span>
{st.heavy.length > 0 && (
<span className="ss-mono">в фоне: {st.heavy.join(', ')}</span>
)}
</div>
)}
</>
)}
</section>
<section className="st-section ss-card">
<h3 className="ss-section">Торренты</h3>
{tUnsupported ? (
<p className="st-hint">
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
</p>
) : (
<>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Блокировать BitTorrent</span>
<span className="st-hint">
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
трекеров большинство торрентов не стартуют. Остальной трафик не режется.
</span>
<span className="st-hint">
{enabled
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
</span>
</span>
<input type="checkbox" checked={tEnabled} disabled={!tst || busy} onChange={e => setTEnabled(e.target.checked)} />
</label>
{tst?.settings.enabled && (
<div className="ss-status">
<span className={`chip ${tst.active ? 'chip--online' : 'chip--offline'}`}>
<span className="chip-dot" />{tst.active ? 'работает' : 'не применено'}
</span>
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
{tst.active && !tst.tcp && (
<span className="st-hint">
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
</span>
)}
{tst.error && <span className="st-hint">{tst.error}</span>}
{tst.seen.length > 0 && (
<>
<span className="st-hint">Пытались качать за последние 10 минут:</span>
<span className="ss-seen">
{tst.seen.map(p => (
<span key={p.ip} className="chip chip--error">
<span className="chip-dot" />{p.name ? `${p.name} · ${p.ip}` : p.ip}
</span>
))}
</span>
</>
)}
</div>
)}
</>
)}
</section>
{error && <p className="login-error">{error}</p>}
{!(unsupported && tUnsupported) && (
<div className="ss-actions">
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
Сохранить
</button>
</div>
)}
</>
);
}
@@ -0,0 +1,11 @@
/*
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.
*/
/* Раздел «Сервер» вкладки «Настройки»: бывший диалог «Настройки сервера», строки .ss-* — из App.css */
.ss-card { gap: 12px; padding: 16px; }
.ss-card .ss-section { margin: 0; }
.ss-card .ss-status { background: var(--surface-container-lowest); }
.ss-actions { display: flex; justify-content: flex-end; }
.ss-card .ss-grid { max-width: 480px; }
+122
View File
@@ -0,0 +1,122 @@
/*
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 (там же .segmented). */
/* Вкладки разделов (MD3 primary tabs, прокручиваемые): 48px, подпись title-small, прижаты влево;
активная — цветом primary и полосой 3px снизу, которая переезжает между вкладками (left/width
ставит компонент). Под вкладками — линия outline-variant во всю ширину. */
.st-tabs {
position: relative;
display: flex;
box-shadow: inset 0 -1px var(--outline-variant); /* линия — тенью: рамка ушла бы в прокрутку */
/* MD3 scrollable tabs: не влезли — прокручиваются вбок, без полосы прокрутки */
overflow-x: auto;
scrollbar-width: none;
}
.st-tabs::-webkit-scrollbar { display: none; }
.st-tab {
flex: 0 0 auto;
height: 48px;
padding: 0 20px;
border: none;
background: transparent;
color: var(--on-surface-variant);
font-family: var(--font);
font-size: var(--title-small-size); line-height: var(--title-small-line);
font-weight: var(--weight-medium);
white-space: nowrap;
cursor: pointer;
transition: color var(--duration-short3) var(--ease-standard);
}
.st-tab[aria-selected="true"] { color: var(--primary); }
.st-tab:focus-visible { outline-offset: -3px; }
.st-tabs-indicator {
position: absolute;
bottom: 0;
height: 3px;
border-radius: 3px 3px 0 0;
background: var(--primary);
transition: left var(--duration-medium2) var(--ease-standard), width var(--duration-medium2) var(--ease-standard);
}
@media (prefers-reduced-motion: reduce) { .st-tabs-indicator { transition: none; } }
/* Содержимое раздела — с тем же шагом, что у страницы. При смене вкладки въезжает на 32px со стороны
выбранной (--st-dir: 1 — справа, -1 — слева, 0 — только проявляется), как MD3 shared axis X.
Только backwards: после анимации transform не остаётся — иначе fixed-диалоги внутри (QR, ключи)
отсчитывались бы от раздела, а не от окна. */
.st-view {
display: flex; flex-direction: column; gap: 20px; min-width: 0;
animation: st-view-in var(--duration-medium2) var(--ease-emphasized-decelerate) backwards;
}
@keyframes st-view-in {
from { opacity: 0; transform: translateX(calc(var(--st-dir, 0) * 32px)); }
}
@media (prefers-reduced-motion: reduce) { .st-view { animation: none; } }
/* въезжающий раздел на миг шире страницы — без горизонтальной прокрутки */
.main:has(.st-view) { overflow-x: hidden; }
/* Раздел — блок на surface-container; строки внутри разделены линией outline-variant */
.st-section {
display: flex; flex-direction: column;
background: var(--surface-container);
border-radius: var(--shape-large);
padding: 12px 16px;
}
.st-lead { margin: 4px 0; }
.st-row {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 8px 16px;
padding: 12px 0;
}
.st-row + .st-row, .st-lead + .st-row { border-top: 1px solid var(--outline-variant); }
.st-row:first-child { padding-top: 4px; }
.st-row:last-child { padding-bottom: 4px; }
.st-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 240px; }
.st-row-name { font-size: var(--body-large-size); line-height: var(--body-large-line); color: var(--on-surface); }
.st-row-ctl { display: inline-flex; flex-wrap: wrap; gap: 8px; }
.st-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); overflow-wrap: anywhere; }
.st-code {
font-family: var(--mono);
font-size: 0.95em;
background: var(--surface-container-highest);
border-radius: var(--shape-extra-small);
padding: 0 4px;
overflow-wrap: anywhere;
}
/* О панели: пары «название — значение» */
.st-facts { display: flex; flex-direction: column; margin: 0; }
.st-facts > div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; }
.st-facts > div + div { border-top: 1px solid var(--outline-variant); }
.st-facts > div:first-child { padding-top: 4px; }
.st-facts > div:last-child { padding-bottom: 4px; }
.st-facts dt { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); }
.st-facts dd { margin: 0; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); text-align: right; }
/* Открытый токен (один раз после выпуска) — как ключ API */
.st-secret {
font-family: var(--mono);
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-surface);
background: var(--surface-container-lowest);
border-radius: var(--shape-medium);
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
/* compact: переключатель темы и кнопки — на всю ширину строки */
@media (max-width: 599px) {
.st-row > .segmented { width: 100%; }
.st-row > .segmented .segmented-item { flex: 1; padding: 0 8px; }
.st-row-ctl { width: 100%; }
.st-row-ctl .btn { flex: 1; }
}
/* Фрагмент prometheus.yml в диалоге токена: переносы строк сохраняются */
.st-yml { white-space: pre-wrap; margin: 0; text-align: left; }
+11 -10
View File
@@ -2,12 +2,14 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode';
import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
type User, type PageProps, type AwgGen,
} from '../../lib/shared';
import { qrDataUrl } from '../../lib/qr';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import './users.css';
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
@@ -28,7 +30,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const data = await apiFetch('GET', '/api/users/stats', tok);
setUsers(prev => prev.map(u => {
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx } : u;
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority, torrent: s.torrent } : u;
}));
} catch { /* silent */ }
}, []);
@@ -107,12 +109,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const vpnKey = u.vpn_key ?? '';
// errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ),
// при 'H' ёмкость QR падает до ~1273 байт и кодирование падает.
const dataUrl = await QRCode.toDataURL(vpnKey, {
width: 560,
margin: 2,
errorCorrectionLevel: 'L',
});
setQrModal({ name, dataUrl, vpnKey });
setQrModal({ name, dataUrl: qrDataUrl(vpnKey, 'L'), vpnKey });
} catch {
showMsg('Не удалось сгенерировать QR-код');
}
@@ -162,6 +159,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<tr>
<th>#</th>
<th>Статус</th>
<th>Приоритет</th>
<th>Имя</th>
<th>IP</th>
<th>↓ rx</th>
@@ -171,7 +169,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
</thead>
<tbody>
{users.length === 0 ? (
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr>
<tr><td colSpan={8} className="empty">Нет пользователей</td></tr>
) : users.map((u, i) => (
<tr key={u.name}>
<td className="td-num">{i + 1}</td>
@@ -185,6 +183,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
: 'никогда'}
</span>
</td>
<td><span className="user-chips"><PriorityChip priority={u.priority} torrent={u.torrent} /><TorrentChip torrent={u.torrent} /></span></td>
<td>
{u.name}
{u.key_gen !== serverGen && (
@@ -251,6 +250,8 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<div className="user-card-ip">
<IcoGlobe />
<span className="user-card-ip-text">{u.ip}</span>
<PriorityChip priority={u.priority} torrent={u.torrent} />
<TorrentChip torrent={u.torrent} />
</div>
<div className="user-card-metrics">
<div className="metric-block">
@@ -291,6 +292,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
/>
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setQrModal(null)}>Закрыть</button>
<button className="btn btn--tonal" onClick={async () => {
try {
const conf = await vpnKeyToConf(qrModal.vpnKey);
@@ -299,7 +301,6 @@ export default function UsersPage({ token, showMsg }: PageProps) {
showMsg('Конфиг скачан');
} catch { showMsg('Не удалось декодировать ключ'); }
}}>Скачать .conf</button>
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
</div>
</div>
</div>
+1
View File
@@ -2,5 +2,6 @@
"id": "users",
"label": "Пользователи",
"icon": "users",
"group": "main",
"order": 1
}
+25 -13
View File
@@ -8,9 +8,9 @@ LICENSE file in the root directory of this source tree.
.user-cards-list { display: none; flex-direction: column; gap: 12px; }
.users-count { font-size: 12px; color: var(--neutral-text); }
.users-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.empty-cards {
font-size: 14px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
@@ -18,7 +18,7 @@ LICENSE file in the root directory of this source tree.
.user-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
border-radius: var(--shape-extra-large);
padding: 16px;
display: flex;
flex-direction: column;
@@ -36,10 +36,10 @@ LICENSE file in the root directory of this source tree.
gap: 8px;
min-width: 0;
}
.user-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
.user-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
.user-card-name {
font-size: 16px;
font-weight: 500;
font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: var(--weight-medium);
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
@@ -47,24 +47,28 @@ LICENSE file in the root directory of this source tree.
}
.user-card-ip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--neutral-text);
}
.user-card-ip-text { font-family: var(--mono); font-size: 13px; }
.user-card-ip-text { font-family: var(--mono); font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
/* чипы приоритета и торрентов — рядом с IP, как в карточке устройства мастер-ключа (на телефоне
.tip-wrap по умолчанию во всю ширину и уводил чип на отдельную строку) */
.user-card-ip .tip-wrap { width: auto; }
.user-card-metrics { display: flex; gap: 8px; }
.metric-block {
flex: 1;
background: var(--surface-container);
border-radius: var(--r-table);
border-radius: var(--shape-large);
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-label { font-size: 12px; color: var(--neutral-text); }
.metric-value { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--on-surface); }
.metric-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.metric-value { font-family: var(--mono); font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.user-card-actions { display: flex; gap: 8px; }
.user-card-actions .btn--outline { flex: 1; }
@@ -72,9 +76,17 @@ LICENSE file in the root directory of this source tree.
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
.user-gen { margin-left: 8px; vertical-align: middle; }
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
@media (max-width: 640px) {
.user-cards-list { display: flex; }
/* compact и medium (< 840px): таблица скрывается (.table-card в App.css), показываются карточки —
в одну колонку на телефоне, сеткой на планшете. */
@media (max-width: 839px) {
.user-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
/* счётчик и «пусто» — на всю строку сетки */
:is(.users-count, .empty-cards) { grid-column: 1 / -1; }
}
@media (max-width: 599px) {
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
.user-gen { width: auto; }
}
/* Чипы приоритета и торрентов в одной ячейке */
.user-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
+164
View File
@@ -0,0 +1,164 @@
/*
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.
*/
/* Токены дизайн-системы Material 3 (design.md, часть I). Компоненты берут значения только
отсюда — без литеральных цветов, размеров шрифта, радиусов и длительностей.
Цвета — тёплая палитра Forgetting, разложенная по ролям MD3: роли, которых раньше не было
(secondary, tertiary, лестница surface-container, inverse), достроены в тех же оттенках. */
:root {
/* ── Цвет: роли MD3 ── */
--primary: #6a45b8;
--on-primary: #ffffff;
--primary-container: #ebddff;
--on-primary-container: #5a2da8;
--inverse-primary: #cbb3ff;
--secondary: #645a73;
--on-secondary: #ffffff;
--secondary-container: #e8dff5;
--on-secondary-container: #4c425a;
--tertiary: #8b5a1f;
--on-tertiary: #ffffff;
--tertiary-container: #ffdcad;
--on-tertiary-container: #7a4a14;
--error: #ba1a1a;
--on-error: #ffffff;
--error-container: #ffdad6;
--on-error-container: #410002;
--surface: #faf6f0;
--surface-dim: #e2d9cc;
--surface-bright: #fdfaf6; /* рабочая зона: карточки, поля, диалоги */
--surface-container-lowest: #ffffff;
--surface-container-low: #f5efe6;
--surface-container: #f0e8dd;
--surface-container-high: #ece2d3;
--surface-container-highest: #e4dac9;
--on-surface: #3a2e22;
--on-surface-variant: #5a4a3a;
--inverse-surface: #3a2e22;
--inverse-on-surface: #fdfaf6;
--outline: #d8cdbe;
--outline-variant: #ece2d3;
/* ── Цвет: статусы (custom colors MD3), тёплый подтон ── */
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-dot: #ba1a1a;
--warn-dot: #e08a2c;
/* ── State layers: непрозрачность слоя MD3 (цвет слоя — цвет содержимого, App.css) ── */
--state-hover: .08;
--state-focus: .10;
--state-pressed: .10;
--state-dragged: .16;
/* недоступные элементы: содержимое и контейнер (on-surface 38% / 12%) */
--disabled-content: color-mix(in srgb, var(--on-surface) 38%, transparent);
--disabled-container: color-mix(in srgb, var(--on-surface) 12%, transparent);
--state-row: rgba(106, 69, 184, .035);
--scrim: rgba(58, 46, 34, .45);
--shadow: rgba(58, 46, 34, .15);
/* ── Типографика: шкала MD3 (размер / интерлиньяж); веса — только 400 и 500.
label-small (11px) не заводим: минимальный размер шрифта — 12px (design.md, 6) ── */
--font: system-ui, -apple-system, 'Inter', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--weight-regular: 400;
--weight-medium: 500;
--headline-small-size: 24px; --headline-small-line: 32px;
--title-large-size: 22px; --title-large-line: 28px; /* emphasized: вес 500 */
--title-medium-size: 16px; --title-medium-line: 24px;
--title-small-size: 14px; --title-small-line: 20px;
--body-large-size: 16px; --body-large-line: 24px;
--body-medium-size: 14px; --body-medium-line: 20px;
--body-small-size: 12px; --body-small-line: 16px;
--label-large-size: 14px; --label-large-line: 20px;
--label-medium-size: 12px; --label-medium-line: 16px;
/* ── Форма: шкала скруглений MD3 ── */
--shape-extra-small: 4px;
--shape-small: 8px;
--shape-medium: 12px;
--shape-large: 16px;
--shape-extra-large: 28px;
--shape-full: 9999px;
/* ── Движение: кривые и длительности MD3 ── */
--ease-standard: cubic-bezier(.2, 0, 0, 1);
--ease-standard-decelerate: cubic-bezier(0, 0, 0, 1);
--ease-standard-accelerate: cubic-bezier(.3, 0, 1, 1);
--ease-emphasized-decelerate: cubic-bezier(.05, .7, .1, 1);
--ease-emphasized-accelerate: cubic-bezier(.3, 0, .8, .15);
--duration-short1: 50ms;
--duration-short2: 100ms;
--duration-short3: 150ms;
--duration-short4: 200ms;
--duration-medium1: 250ms;
--duration-medium2: 300ms;
--duration-medium3: 350ms;
--duration-medium4: 400ms;
/* ── Элевация MD3: уровни 1–3 (тени цвета --shadow) ── */
--elevation-1: 0 1px 2px var(--shadow), 0 1px 3px 1px var(--shadow);
--elevation-2: 0 1px 2px var(--shadow), 0 2px 6px 2px var(--shadow);
--elevation-3: 0 1px 3px var(--shadow), 0 4px 8px 3px var(--shadow);
}
:root[data-theme="dark"] {
--primary: #cbb3ff;
--on-primary: #381b6c;
--primary-container: #52389e;
--on-primary-container: #ebddff;
--inverse-primary: #6a45b8;
--secondary: #cec1de;
--on-secondary: #352c43;
--secondary-container: #483a55;
--on-secondary-container: #ebddff;
--tertiary: #f2bd7c;
--on-tertiary: #4a2800;
--tertiary-container: #4a3218;
--on-tertiary-container: #ffdcad;
--error: #ffb4ab;
--on-error: #690005;
--error-container: #5c1414;
--on-error-container: #ffb4ab;
/* surface-bright светлее канона MD3 (тон 15 вместо 24): рабочая зона в тёмной теме
чуть приподнята над фоном, а не выбелена */
--surface: #1a1611;
--surface-dim: #1a1611;
--surface-bright: #2b251d;
--surface-container-lowest: #14100b;
--surface-container-low: #201a14;
--surface-container: #251f18;
--surface-container-high: #302820;
--surface-container-highest: #3a332a;
--on-surface: #ede2d4;
--on-surface-variant: #b6a994;
--inverse-surface: #ede2d4;
--inverse-on-surface: #2b251d;
--outline: #4d4334;
--outline-variant: #342c20;
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-dot: #ff5449;
--warn-dot: #ffb76b;
--state-row: rgba(203, 179, 255, .05);
--scrim: rgba(0, 0, 0, .55);
--shadow: rgba(0, 0, 0, .45);
}
+883 -188
View File
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -4,15 +4,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const api = `http://localhost:${process.env.MOCK_PORT || 8080}`;
export default defineConfig({
// Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в
// подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же
// (main.tsx).
base: './',
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'/ui': 'http://localhost:8080',
'/health': 'http://localhost:8080',
'/login': 'http://localhost:8080',
'/logout': 'http://localhost:8080',
'/api': api,
'/ui': api,
'/health': api,
'/login': api,
'/logout': api,
},
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.3.1",
"version": "0.5.0",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+173 -15
View File
@@ -9,6 +9,7 @@ import * as readline from "readline";
import * as crypto from "crypto";
import * as http from "http";
import path from "path";
import { monitorCmd } from "./monitor";
const ROOT = path.resolve(__dirname, "../..");
const LOGS = path.join(ROOT, "logs");
@@ -100,10 +101,17 @@ const SERVICES = {
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
// Публичный адрес панели в sen://-ссылках и endpoints. Пусто — адрес локального
// сервера (у панели без своего VPN — тот, по которому к ней зашли).
SUB_HOST: process.env.SUB_HOST ?? "",
// Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
// core без собственного VPN: awg-ctrl рядом не нужен.
NODE_PORT: process.env.NODE_PORT ?? "",
// Часовой аудит «мёртвых» пиров на всех серверах — только у core (у ноды со своей панелью выключен).
AUDIT: process.env.CORE_HOST ? "off" : (process.env.AUDIT ?? "on"),
// Пер-пировые серии в /metrics: только по явному согласию оператора (cli.env).
METRICS_PEERS: process.env.METRICS_PEERS ?? "off",
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",
@@ -141,7 +149,13 @@ type SvcName = keyof typeof SERVICES;
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
const IS_NODE = !!process.env.CORE_HOST;
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
const ALL: SvcName[] = IS_NODE ? ["awgctrl", "agent"] : NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
// Нода со своей панелью: работающий сервер «панель + VPN» подключили к главной панели, не
// выключая его собственную (join без --no-panel). Обе панели ходят в один awg-ctrl — он и
// есть источник правды, поэтому друг другу они не мешают. У ноды, поставленной install.sh,
// панели нет (UI_PASS не задан).
const NODE_PANEL = IS_NODE && !!process.env.UI_PASS && (process.env.NODE_PANEL ?? "on").toLowerCase() !== "off";
const ALL: SvcName[] = IS_NODE ? (NODE_PANEL ? ["awgctrl", "ui", "agent"] : ["awgctrl", "agent"])
: NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
function readPid(name: SvcName): number | null {
const { pidFile } = SERVICES[name];
@@ -319,12 +333,12 @@ async function startAll() {
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
await start("awgctrl");
const next: SvcName = IS_NODE ? "agent" : "ui";
const rest = ALL.filter(n => n !== "awgctrl");
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
console.error(` ${grey("■")} awg-ctrl не отвечает — ${SERVICES[next].label} не запущен (см. ${SERVICES.awgctrl.logFile})`);
console.error(` ${grey("■")} awg-ctrl не отвечает — ${rest.map(n => SERVICES[n].label).join(", ")} не запущен (см. ${SERVICES.awgctrl.logFile})`);
return;
}
await start(next);
for (const n of rest) await start(n);
}
async function stopAll() { for (const n of ALL) await stop(n); }
@@ -347,11 +361,12 @@ function restartFresh(): void {
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
}
// `join <awgjoin://…>`: превратить эту установку в ноду. Панель (awg-ui) на этой машине
// останавливается, вместо неё запускается агент; awg-ctrl и пользователи остаются как были.
async function joinCore(link?: string) {
if (!link?.startsWith("awgjoin://")) {
console.error(" Usage: awg-ctrl join <awgjoin://…> (ссылку даёт панель: «Добавить сервер»)");
// `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как ноду. Рядом
// запускается агент; awg-ctrl и пользователи остаются как были. Своя панель (awg-ui) продолжает
// работать, если она здесь есть, — с --no-panel она останавливается.
async function joinCore(link?: string, flag?: string) {
if (!link?.startsWith("awgjoin://") || (flag !== undefined && flag !== "--no-panel")) {
console.error(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)");
process.exit(1);
}
let j: { v?: number; h?: string; p?: number; pin?: string; n?: number; s?: string };
@@ -364,23 +379,164 @@ async function joinCore(link?: string) {
if (!ok) { console.error(" Ссылка неверного формата или от другой версии панели"); process.exit(1); }
if (IS_NODE && !await confirmYes(` Эта нода уже подключена к ${process.env.CORE_HOST}. Перепривязать?`)) return;
const keepPanel = flag !== "--no-panel" && !!process.env.UI_PASS;
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
const set: Record<string, string> = {
CORE_HOST: j.h!, CORE_PORT: String(j.p), CORE_PIN: j.pin!, NODE_ID: String(j.n), JOIN_SECRET: j.s!,
NODE_PANEL: keepPanel ? "on" : "off",
};
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
writeFileSync(envFile, content, { mode: 0o600 });
await stop("ui"); // панель на ноде не нужна
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}`);
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
const dropPeers = !keepPanel && process.env.UI_PASS ? await askDropPanelPeers() : false;
if (!keepPanel) await stop("ui");
if (dropPeers) await dropArea(localPanelId()!);
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
restartFresh();
}
// ── Пиры подписки (sen://) на локальном awg-ctrl ──────────────────────────────
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели.
// У ноды со своей панелью на одном awg-ctrl две области (своя и core). Пиры без области
// («m1/d2») выданы до областей. Снимает их awg-ctrl по полному списку от панели
// (PUT /api/peers/sync); пустой список = снять всю область. Здесь — ручной рычаг для
// оператора: панель выключена или core пропал насовсем. По времени ничего не удаляется.
const PANEL_ID_FILE = path.join(ROOT, "awg-ui", "panel.id");
const AREA_RE = /^[a-z0-9]{8,16}$/;
const LEGACY_OWNER = /^m\d+\/d\d+$/;
function localPanelId(): string | null {
try { const id = readFileSync(PANEL_ID_FILE, "utf8").trim(); return AREA_RE.test(id) ? id : null; }
catch { return null; }
}
// Тот же короткоживущий токен, что awg-ui и awg-agent шлют awg-ctrl (Ed25519, 60 с).
function mintInternalToken(): string {
const key = crypto.createPrivateKey(readFileSync(SERVICES.ui.env.INTERNAL_AUTH_KEY_FILE));
const t = Math.floor(Date.now() / 1000);
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
const p = Buffer.from(JSON.stringify({ iss: "awg-cli", iat: t, exp: t + 60 })).toString("base64url");
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), key).toString("base64url")}`;
}
function ctrlCall(method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const req = http.request({
host: "127.0.0.1", port: Number(SERVICES.awgctrl.env.PORT), method, path: urlPath, timeout: 30_000,
headers: {
Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json",
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
},
}, res => {
let raw = "";
res.on("data", c => { raw += c; });
res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, data: null }); } });
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("timeout")));
req.end(payload);
});
}
interface CtrlPeer { pub_key: string; ip: string; owner: string; online: boolean }
async function listPeers(): Promise<CtrlPeer[]> {
const r = await ctrlCall("GET", "/api/peers");
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
return r.data.peers as CtrlPeer[];
}
// Область пира: id панели, «legacy» (до областей) или null (чужой формат — не наш).
function areaOf(owner: string): string | null {
const head = owner.split("/")[0];
if (owner.includes("/") && AREA_RE.test(head)) return head;
return LEGACY_OWNER.test(owner) ? "legacy" : null;
}
const peersWord = (n: number) =>
n % 10 === 1 && n % 100 !== 11 ? "пир" : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? "пира" : "пиров";
async function askDropPanelPeers(): Promise<boolean> {
const own = localPanelId();
if (!own) return false;
let n: number;
try { n = (await listPeers()).filter(p => areaOf(p.owner) === own).length; }
catch (e) { console.warn(` ${dim(`Не удалось посмотреть пиров подписки: ${(e as Error).message}`)}`); return false; }
if (n === 0) return false;
const preset = (process.env.NODE_PEERS ?? "").toLowerCase();
if (preset === "keep" || preset === "drop") return preset === "drop";
console.log(`\n На этой панели ${n} ${peersWord(n)} подписки (устройства мастер-ключей). Без панели ими некому управлять:`);
console.log(` 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin`);
console.log(` 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)`);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const a = await new Promise<string>(r => rl.question(" Выбор [1]: ", r));
rl.close();
return a.trim() === "2";
}
// Снять всю область: для панели — пустым списком через sync, для legacy — по одному.
async function dropArea(area: string): Promise<void> {
try {
if (area === "legacy") {
const legacy = (await listPeers()).filter(p => areaOf(p.owner) === "legacy");
for (const p of legacy) await ctrlCall("DELETE", `/api/peers/${Buffer.from(p.pub_key, "base64").toString("base64url")}`);
console.log(` ${green("✓")} Снято пиров без области: ${legacy.length}`);
return;
}
const r = await ctrlCall("PUT", "/api/peers/sync", { owner: area, keep: [] });
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
console.log(` ${green("✓")} Снято пиров области ${area}: ${r.data.removed}`);
} catch (e) {
console.error(` Не удалось снять пиров: ${(e as Error).message}`);
}
}
async function peersCmd(sub?: string, arg?: string) {
const own = localPanelId();
let peers: CtrlPeer[];
try { peers = await listPeers(); }
catch (e) { console.error(` awg-ctrl недоступен: ${(e as Error).message}`); process.exit(1); }
const areas = new Map<string, CtrlPeer[]>();
for (const p of peers) { const a = areaOf(p.owner); if (a) areas.set(a, [...(areas.get(a) ?? []), p]); }
if (sub === undefined) {
if (!areas.size) { console.log(" Пиров подписки (sen://) на этом сервере нет"); return; }
console.log("\n Пиры подписки (sen://) на этом сервере:\n");
for (const [a, list] of areas) {
const mark = a === own ? "эта панель" : a === "legacy" ? "без области (выданы до областей)" : "";
console.log(` ${bold(a.padEnd(16))} ${String(list.length).padStart(4)} ${peersWord(list.length).padEnd(5)}` +
` онлайн ${list.filter(p => p.online).length} ${dim(mark)}`);
}
console.log(`\n ${dim("Снять область целиком: awg-ctrl peers drop <область>")}`);
return;
}
if (sub !== "drop" || !arg || !(arg === "legacy" || AREA_RE.test(arg))) {
console.error(" Usage: awg-ctrl peers [drop <область|legacy>]");
process.exit(1);
}
const n = areas.get(arg)?.length ?? 0;
if (!n) { console.log(` В области ${arg} пиров нет`); return; }
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
if (arg === own && isRunning("ui")) {
console.error(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней");
process.exit(1);
}
console.log(` ${dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")}`);
console.log(` ${dim("сперва удалите сервер в ней (или мастер-ключи).")}`);
if (!await confirmYes(` Снять ${n} ${peersWord(n)} области ${arg}? Их устройства потеряют доступ к этому серверу.`)) return;
await dropArea(arg);
}
async function unjoinCore() {
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
await stop("agent");
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET"]) content = removeEnvVar(content, k);
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"]) content = removeEnvVar(content, k);
writeFileSync(envFile, content, { mode: 0o600 });
console.log(` ${green("✓")} Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart`);
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
@@ -489,9 +645,9 @@ async function main() {
return;
}
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…>|unjoin>";
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>";
const [,,, sub] = process.argv;
const [,,, sub, arg] = process.argv;
switch (cmd) {
case "start": await startAll(); break;
@@ -503,8 +659,10 @@ async function main() {
else if (sub === "pass") await setCredentials("pass");
else await setCredentials();
break;
case "join": await joinCore(sub); break;
case "join": await joinCore(sub, arg); break;
case "unjoin": await unjoinCore(); break;
case "peers": await peersCmd(sub, arg); break;
case "monitor": await monitorCmd(sub); break;
default: console.log(USAGE); process.exit(1);
}
}
+170
View File
@@ -0,0 +1,170 @@
// 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-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор».
// Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не ставит, она только просит
// (через awg-ctrl) или показывает эту команду. Всё слушает 127.0.0.1: наружу Prometheus не торчит,
// а панель читает его по HTTP на той же машине.
import { spawnSync } from "child_process";
import { readFileSync, writeFileSync, existsSync, copyFileSync, unlinkSync } from "fs";
import * as crypto from "crypto";
import * as http from "http";
const AWG_DIR = "/etc/amnezia/amneziawg";
const PROM_DIR = "/etc/prometheus";
const CONF_FILE = `${PROM_DIR}/prometheus.yml`;
const TOKEN_FILE = `${PROM_DIR}/forgetting.token`;
// Хэш локального токена для awg-ui: читает его при проверке /metrics (сам токен знает только Prometheus).
export const MONITOR_HASH_FILE = `${AWG_DIR}/monitor_token.sha256`;
const DEFAULTS_FILE = "/etc/default/prometheus";
const MARKER = "# managed by forgetting";
const LISTEN = "127.0.0.1:9090";
const RETENTION = "31d"; // с запасом на вид «30 дней» во вкладке «Монитор»
const log = (s: string) => console.log(` ${s}`);
function fail(s: string): never { console.error(`\n ✗ ${s}\n`); process.exit(1); }
function sh(cmd: string, args: string[], opts: { quiet?: boolean } = {}) {
return spawnSync(cmd, args, {
encoding: "utf8",
stdio: opts.quiet ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
env: { ...process.env, DEBIAN_FRONTEND: "noninteractive" },
});
}
function osFamily(): string {
try {
const t = readFileSync("/etc/os-release", "utf8");
const id = t.match(/^ID=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
const like = t.match(/^ID_LIKE=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
return `${id} ${like}`;
} catch { return ""; }
}
function ready(timeoutMs = 1500): Promise<boolean> {
return new Promise(resolve => {
const [host, port] = LISTEN.split(":");
const req = http.get({ host, port: Number(port), path: "/-/ready", timeout: timeoutMs }, res => {
res.resume(); resolve(res.statusCode === 200);
});
req.on("timeout", () => { req.destroy(); resolve(false); });
req.on("error", () => resolve(false));
});
}
const installed = () => sh("dpkg", ["-s", "prometheus"], { quiet: true }).status === 0;
const active = () => sh("systemctl", ["is-active", "--quiet", "prometheus"], { quiet: true }).status === 0;
const managed = () => existsSync(CONF_FILE) && readFileSync(CONF_FILE, "utf8").includes(MARKER);
// Новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран.
function authBlock(): string {
const v = sh("prometheus", ["--version"], { quiet: true });
const m = `${v.stdout ?? ""}${v.stderr ?? ""}`.match(/version (\d+)\.(\d+)/);
const [maj, min] = m ? [Number(m[1]), Number(m[2])] : [2, 0];
return maj > 2 || (maj === 2 && min >= 26)
? ` authorization:\n credentials_file: ${TOKEN_FILE}\n`
: ` bearer_token_file: ${TOKEN_FILE}\n`;
}
function writeConfig(uiPort: string) {
if (existsSync(CONF_FILE)) copyFileSync(CONF_FILE, `${CONF_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
writeFileSync(CONF_FILE, `${MARKER}
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: forgetting
scheme: http
metrics_path: /metrics
${authBlock()} static_configs:
- targets: ['127.0.0.1:${uiPort}']
`, { mode: 0o644 });
}
// Токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш.
function writeToken() {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
writeFileSync(TOKEN_FILE, token, { mode: 0o640 });
sh("chown", ["root:prometheus", TOKEN_FILE], { quiet: true });
writeFileSync(MONITOR_HASH_FILE, crypto.createHash("sha256").update(token).digest("hex") + "\n", { mode: 0o644 });
}
function setArgs() {
const line = `ARGS="--web.listen-address=${LISTEN} --storage.tsdb.retention.time=${RETENTION}"`;
let text = existsSync(DEFAULTS_FILE) ? readFileSync(DEFAULTS_FILE, "utf8") : "";
if (text) copyFileSync(DEFAULTS_FILE, `${DEFAULTS_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
text = /^ARGS=/m.test(text) ? text.replace(/^ARGS=.*$/m, line) : `${text.replace(/\n?$/, "\n")}${line}\n`;
writeFileSync(DEFAULTS_FILE, text, { mode: 0o644 });
}
async function install() {
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor install");
if (!/debian|ubuntu/i.test(osFamily())) fail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).");
const uiPort = process.env.UI_PORT;
if (!uiPort) fail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.");
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
if (installed() && existsSync(CONF_FILE) && !managed()) {
fail(`Prometheus уже установлен и настроен не нами (${CONF_FILE}). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.`);
}
log("1/4 установка пакета prometheus");
if (!installed()) {
if (sh("apt-get", ["update", "-qq"]).status !== 0) fail("apt-get update завершился ошибкой");
if (sh("apt-get", ["install", "-y", "--no-install-recommends", "prometheus"]).status !== 0) fail("apt-get install prometheus завершился ошибкой");
} else log("уже установлен");
log("2/4 токен и конфиг");
writeToken();
writeConfig(uiPort);
log(`3/4 слушаем только ${LISTEN}, хранение ${RETENTION}`);
setArgs();
log("4/4 запуск");
sh("systemctl", ["enable", "prometheus"], { quiet: true });
if (sh("systemctl", ["restart", "prometheus"]).status !== 0) fail("prometheus не запустился: journalctl -u prometheus");
for (let i = 0; i < 30; i++) {
if (await ready()) { console.log(`\n ✓ Prometheus работает на ${LISTEN}, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n`); return; }
await new Promise(r => setTimeout(r, 1000));
}
fail("Prometheus не ответил за 30 с: journalctl -u prometheus");
}
async function status() {
const ok = await ready();
console.log(` пакет: ${installed() ? "установлен" : "нет"}`);
console.log(` сервис: ${active() ? "работает" : "остановлен"}`);
console.log(` наш конфиг: ${managed() ? "да" : "нет"}`);
console.log(` готов: ${ok ? `да, ${LISTEN}` : "нет"}`);
}
function remove() {
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor remove");
// Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили.
if (installed() && existsSync(CONF_FILE) && !managed()) {
fail(`Prometheus настроен не нами (${CONF_FILE}), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus`);
}
log("1/3 остановка сервиса");
sh("systemctl", ["disable", "--now", "prometheus"], { quiet: true });
log("2/3 токен");
for (const f of [TOKEN_FILE, MONITOR_HASH_FILE]) { try { unlinkSync(f); } catch { /* уже нет */ } }
log("3/3 удаление пакета и накопленных данных");
if (installed() && sh("apt-get", ["purge", "-y", "prometheus"]).status !== 0) fail("apt-get purge prometheus завершился ошибкой");
console.log("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n");
}
export async function monitorCmd(sub?: string) {
switch (sub) {
case "install": await install(); break;
case "status": await status(); break;
case "remove": remove(); break;
default: console.log("Usage: awg-ctrl monitor <install|status|remove>"); process.exit(1);
}
}
+74
View File
@@ -0,0 +1,74 @@
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent / relay и команды оператора.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/maeneko/forgetting/internal/agent"
"github.com/maeneko/forgetting/internal/cli"
"github.com/maeneko/forgetting/internal/ctrl"
"github.com/maeneko/forgetting/internal/relay"
"github.com/maeneko/forgetting/internal/ui"
)
// version — из сборки релиза (-ldflags "-X main.version=…").
var version = "dev"
func main() {
// Без аргументов или с командой оператора — CLI (меню, start/stop/…), как прежний awg-ctrl.
if len(os.Args) < 2 {
cli.Main(nil)
return
}
switch os.Args[1] {
case "version":
fmt.Println(version)
case "ctrl":
s, err := ctrl.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := s.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "ui":
a, err := ui.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := a.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "agent":
c, err := agent.ConfigFromEnv()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
a, err := agent.New(c)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
a.Run(ctx)
case "relay":
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := relay.Main(ctx); err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
cli.Main(os.Args[1:])
}
}
+17
View File
@@ -0,0 +1,17 @@
module github.com/maeneko/forgetting
go 1.26.0
require (
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+1077 -524
View File
File diff suppressed because it is too large Load Diff
+730
View File
@@ -0,0 +1,730 @@
// Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее
// WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет
// присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет,
// awg-ctrl остаётся закрытым от сети. Если на ноде стоит Prometheus (`awg-ctrl monitor install`),
// агент отдаёт ему метрики панели, взятые у core, и пропускает к нему запросы вкладки «Монитор».
// Протокол и формат join-строки — docs/node-protocol.md.
package agent
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64 = base64.RawURLEncoding
const (
handshakeTimeout = 10 * time.Second
deadAfter = 60 * time.Second // core пингует чаще; тишина дольше — соединение мёртвое
maxPayload = 4 * 1024 * 1024
ctrlTimeout = 110 * time.Second
maxBackoff = 60 * time.Second
closeAuthFailed = 4003
promTimeout = 8 * time.Second // как у панели к своему Prometheus
metricsTimeout = 9 * time.Second // меньше scrape_timeout Prometheus (10 с по умолчанию)
)
type Config struct {
CoreHost string
CorePort int
CoreEndpoint string // «host:port», который core сообщил сам (после переезда панели); пробуется первым
CorePin string // SHA-256 SPKI сертификата core, base64url
NodeID int
Ctrl string // http://127.0.0.1:PORT
NodeKeyFile string
InternalKey string
CLIEnvFile string
JoinSecret string // одноразовый, живёт до первого успешного подключения
Version string
PromURL string // Prometheus этой машины: к нему агент пропускает запросы панели (/prom/…)
MetricsPort int // 127.0.0.1:порт, где Prometheus ноды скрейпит метрики панели; 0 — мониторинга на ноде нет
MonitorHash string // хэш токена этого Prometheus (его пишет `awg-ctrl monitor install`)
Logf, Errorf func(format string, a ...any)
}
// envFromFile — значение KEY=… из .env (как в Node-версии: первая строка, обрезанная).
func envFromFile(file, key string) string {
raw, err := os.ReadFile(file)
if err != nil {
return ""
}
m := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(.*)$`).FindSubmatch(raw)
if m == nil {
return ""
}
return strings.TrimSpace(string(m[1]))
}
// ConfigFromEnv собирает конфиг так же, как Node-агент. Каталог агента — рабочий каталог
// процесса (CLI запускает сервис с cwd = <root>/awg-agent): там node.key по умолчанию, а
// .env проекта — уровнем выше.
func ConfigFromEnv() (Config, error) {
cwd, _ := os.Getwd()
atoi := func(k string) int { n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(k))); return n }
// Нода №0 — сервер, с которого переехала панель: 0 здесь — значение, а не «не задано».
nodeID, idErr := strconv.Atoi(strings.TrimSpace(os.Getenv("NODE_ID")))
or := func(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
c := Config{
CoreHost: os.Getenv("CORE_HOST"),
CorePort: atoi("CORE_PORT"),
CorePin: os.Getenv("CORE_PIN"),
CoreEndpoint: os.Getenv("CORE_ENDPOINT"),
NodeID: nodeID,
Ctrl: "http://127.0.0.1:" + or("AWGCTRL_PORT", "3005"),
NodeKeyFile: or("NODE_KEY_FILE", filepath.Join(cwd, "node.key")),
InternalKey: or("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key"),
CLIEnvFile: os.Getenv("CLI_ENV_FILE"),
JoinSecret: os.Getenv("JOIN_SECRET"),
Version: os.Getenv("VERSION"),
PromURL: or("PROM_URL", "http://127.0.0.1:9090"),
MetricsPort: atoi("AGENT_METRICS_PORT"),
MonitorHash: or("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"),
}
if c.Version == "" {
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION")
}
if c.CoreHost == "" || c.CorePort == 0 || c.CorePin == "" || idErr != nil || c.NodeID < 0 {
return c, errors.New("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`")
}
return c, nil
}
type Agent struct {
c Config
nodeKey ed25519.PrivateKey
nodePubRaw string
internalKey ed25519.PrivateKey // nil — запросы к awg-ctrl уйдут без токена и получат 401
mu sync.Mutex
joinSecret string
addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT)
cur int
link *coreLink // соединение после ready; nil — core сейчас недоступен
http *http.Client
prom *http.Client
}
// coreLink — запросы ноды к core (пока один: метрики панели для Prometheus ноды).
type coreLink struct {
send func(v any)
done <-chan struct{}
mu sync.Mutex
seq int64
pending map[int64]chan reply
}
// loadNodeKey — личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
func loadNodeKey(file string, logf func(string, ...any)) (ed25519.PrivateKey, error) {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
if err := os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
return nil, err
}
logf("agent: создан ключ ноды %s", file)
}
return iauth.LoadPrivate(file)
}
func New(c Config) (*Agent, error) {
if c.Logf == nil {
c.Logf = func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
}
if c.Errorf == nil {
c.Errorf = func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
}
key, err := loadNodeKey(c.NodeKeyFile, c.Logf)
if err != nil {
return nil, fmt.Errorf("agent: ключ ноды %s: %w", c.NodeKeyFile, err)
}
a := &Agent{
c: c, nodeKey: key, joinSecret: c.JoinSecret,
nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)),
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout},
prom: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: promTimeout},
}
a.setAddrs(c.CoreEndpoint)
if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil {
a.internalKey = ik
} else {
c.Errorf("agent: приватный ключ внутренней авторизации не найден: %s", c.InternalKey)
}
return a, nil
}
// authMsg дублируется в awg-ui/nodes.ts.
func authMsg(nonce string, nodeID int) []byte {
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID))
}
// preauthMsg — подпись в заголовке запроса на WebSocket: хаб проверяет её до апгрейда и без неё
// отвечает 404, как пустой сервер (internal/ui/hub_preauth.go, там же копия).
func preauthMsg(nodeID int, ts int64) []byte {
return []byte("awg-node-pre-v1\n" + strconv.Itoa(nodeID) + "\n" + strconv.FormatInt(ts, 10))
}
// preauthHeader — подпись ноды (и при первой привязке — секрет с ключом) для запроса на WebSocket.
func (a *Agent) preauthHeader() http.Header {
ts := time.Now().Unix()
h := http.Header{}
h.Set("X-Node-Id", strconv.Itoa(a.c.NodeID))
h.Set("X-Node-Ts", strconv.FormatInt(ts, 10))
h.Set("X-Node-Sig", b64.EncodeToString(ed25519.Sign(a.nodeKey, preauthMsg(a.c.NodeID, ts))))
if s := a.secret(); s != "" {
h.Set("X-Node-Join", s)
h.Set("X-Node-Pub", a.nodePubRaw)
}
return h
}
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
var rePath = regexp.MustCompile(`^/[\x21-\x7e]*$`)
// pathAllowed — агент не открытый прокси: только API самого awg-ctrl. Строже Node-версии
// там, где та полагалась на нормализацию WHATWG URL: обратный слэш и «//» в начале
// (смена хоста) отвергаются сразу, «.»/«..» — и в %2e-записи.
func pathAllowed(p any) (string, bool) {
s, ok := p.(string)
if !ok || len(s) > 2048 || !rePath.MatchString(s) || strings.HasPrefix(s, "//") || strings.Contains(s, `\`) {
return "", false
}
pathname := s
if i := strings.IndexAny(pathname, "?#"); i >= 0 {
pathname = pathname[:i]
}
for _, seg := range strings.Split(pathname, "/") {
switch strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(seg, "%2e", "."), "%2E", ".")) {
case ".", "..":
return "", false
}
}
if pathname == "/health" || strings.HasPrefix(pathname, "/api/") || strings.HasPrefix(pathname, "/awg/") {
return s, true
}
return "", false
}
type reply struct {
status int
data any
}
func errReply(status int, msg string) reply {
return reply{status, jsjson.Object{{Key: "error", Val: msg}}}
}
// execute выполняет запрос core на локальном awg-ctrl (или, для /prom/…, на Prometheus).
func (a *Agent) execute(m jsjson.Object) reply {
method, _ := m.Get2("method").(string)
method = strings.ToUpper(method)
if s, _ := m.Get2("path").(string); strings.HasPrefix(s, "/prom/") {
return a.execProm(method, s)
}
p, ok := pathAllowed(m.Get2("path"))
if !methods[method] || !ok {
return errReply(400, "Запрос не разрешён агентом")
}
var body io.Reader
if v, has := m.Get("body"); has && v != nil {
raw, err := jsjson.Marshal(v)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
body = bytes.NewReader(raw)
}
u, err := url.Parse(a.c.Ctrl + p)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
u.Fragment = ""
req, err := http.NewRequest(method, u.String(), body)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
req.Header.Set("Content-Type", "application/json")
if a.internalKey != nil {
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now()))
}
return do(a.http, req, "awg-ctrl недоступен")
}
// do — ответ как его разобрал бы axios: JSON — значением, иначе строкой (пустое тело — "").
func do(c *http.Client, req *http.Request, down string) reply {
resp, err := c.Do(req)
if err != nil {
return errReply(502, down)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errReply(502, down)
}
if len(raw) == 0 {
return reply{resp.StatusCode, ""}
}
if v, err := jsjson.Decode(raw); err == nil {
return reply{resp.StatusCode, v}
}
return reply{resp.StatusCode, string(raw)}
}
// promPaths — всё, что панель читает у Prometheus: готовность и фиксированные запросы вкладки.
var promPaths = map[string]bool{"/prom/-/ready": true, "/prom/api/v1/query": true, "/prom/api/v1/query_range": true}
// promAllowed — запрос панели к Prometheus этой машины: только GET и только пути из promPaths.
func promAllowed(method string, p any) (string, bool) {
s, ok := p.(string)
if method != "GET" || !ok || len(s) > 8192 || !rePath.MatchString(s) {
return "", false
}
pathname, query, _ := strings.Cut(s, "?")
if !promPaths[pathname] || strings.Contains(query, "#") {
return "", false
}
return strings.TrimPrefix(s, "/prom"), true
}
// execProm — Prometheus ноды для вкладки «Монитор» панели. Пропускаем, только если он
// скрейпит панель через этого агента (AGENT_METRICS_PORT): чужой Prometheus на машине
// панели не отдаём, да и метрик панели в нём нет.
func (a *Agent) execProm(method string, p any) reply {
if a.c.MetricsPort == 0 {
return errReply(404, "Prometheus на этой ноде не подключён к панели")
}
rest, ok := promAllowed(method, p)
if !ok {
return errReply(400, "Запрос не разрешён агентом")
}
req, err := http.NewRequest("GET", strings.TrimSuffix(a.c.PromURL, "/")+rest, nil)
if err != nil {
return errReply(400, "Запрос не разрешён агентом")
}
return do(a.prom, req, "Prometheus не отвечает")
}
// ── Соединение с core ──────────────────────────────────────────────────────
func spkiPin(rawCert []byte) (string, error) {
cert, err := x509.ParseCertificate(rawCert)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64.EncodeToString(sum[:]), nil
}
var errPin = errors.New("сертификат core не совпал с pin — соединение отклонено")
// tlsConfig — сертификат core самоподписанный: цепочку не проверяем, доверие даёт pin.
// Проверка внутри TLS-рукопожатия: секрет и подпись уходят только тому, чей сертификат
// совпал с pin из join-строки.
func (a *Agent) tlsConfig() *tls.Config {
return &tls.Config{
InsecureSkipVerify: true,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errPin
}
got, err := spkiPin(rawCerts[0])
if err != nil || got != a.c.CorePin {
return errPin
}
return nil
},
}
}
// forgetJoinSecret — после первого успешного входа секрет больше не нужен, убираем с диска.
func (a *Agent) forgetJoinSecret() {
a.mu.Lock()
a.joinSecret = ""
a.mu.Unlock()
os.Unsetenv("JOIN_SECRET")
if a.c.CLIEnvFile == "" {
return
}
raw, err := os.ReadFile(a.c.CLIEnvFile)
if err != nil {
return // не критично: секрет одноразовый и с истекающим сроком
}
var kept []string
for _, l := range strings.Split(string(raw), "\n") {
if !strings.HasPrefix(l, "JOIN_SECRET=") {
kept = append(kept, l)
}
}
os.WriteFile(a.c.CLIEnvFile, []byte(strings.Join(kept, "\n")), 0o600)
}
// setAddrs — сначала адрес, который сообщил core, потом адрес из join: как endpoints у клиента
// sen://. Чужой сервер по нему не подменить — доверие даёт pin, а не адрес.
func (a *Agent) setAddrs(learned string) {
a.mu.Lock()
defer a.mu.Unlock()
a.addrs = nil
for _, s := range []string{learned, hostPort(a.c.CoreHost, a.c.CorePort)} {
if s != "" && (len(a.addrs) == 0 || a.addrs[0] != s) {
a.addrs = append(a.addrs, s)
}
}
a.cur = 0
}
func (a *Agent) addr() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.addrs[a.cur%len(a.addrs)]
}
// next — не дозвонились (или core не принял): следующий адрес.
func (a *Agent) next() {
a.mu.Lock()
a.cur = (a.cur + 1) % len(a.addrs)
a.mu.Unlock()
}
// learnCore — core сообщил свой адрес (панель переехала, старый сервер скоро выключат):
// запоминаем в cli.env, чтобы после перезапуска звонить сразу туда. false — не запомнили.
func (a *Agent) learnCore(addr string) bool {
host, port, err := net.SplitHostPort(addr)
if p, perr := strconv.Atoi(port); err != nil || perr != nil || p <= 0 || p > 65535 || !reCoreHost.MatchString(host) {
return false
}
addr = hostPort(host, mustAtoi(port))
if addr == a.c.CoreEndpoint {
return true
}
if a.c.CLIEnvFile == "" {
return false
}
if err := envfile.Set(a.c.CLIEnvFile, [][2]string{{"CORE_ENDPOINT", addr}}); err != nil {
a.c.Errorf("agent: адрес core не сохранён: %v", err)
return false
}
a.c.CoreEndpoint = addr
a.mu.Lock()
cur := a.addrs[a.cur%len(a.addrs)]
a.mu.Unlock()
a.setAddrs(addr)
a.mu.Lock()
for i, s := range a.addrs { // текущее соединение не рвём: адрес пригодится при следующем
if s == cur {
a.cur = i
}
}
a.mu.Unlock()
a.c.Logf("agent: core теперь доступен и по %s", addr)
return true
}
var reCoreHost = regexp.MustCompile(`^[A-Za-z0-9.:-]{1,253}$`)
func mustAtoi(s string) int { n, _ := strconv.Atoi(s); return n }
func (a *Agent) secret() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.joinSecret
}
// connectOnce держит одно соединение до его закрытия. authFailed — core закрыл с 4003.
func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var dead *time.Timer
var deadMu sync.Mutex
armDead := func() {
deadMu.Lock()
defer deadMu.Unlock()
if dead != nil {
dead.Stop()
}
dead = time.AfterFunc(deadAfter, cancel)
}
defer func() {
deadMu.Lock()
if dead != nil {
dead.Stop()
}
deadMu.Unlock()
}()
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout)
ws, resp, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{
HTTPClient: &http.Client{Transport: tr},
HTTPHeader: a.preauthHeader(),
CompressionMode: websocket.CompressionDisabled,
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
})
dcancel()
if err != nil {
// 404 до апгрейда — хаб не принял подпись (или IP временно заблокирован за неудачи):
// как отказ в auth, переподключение — не чаще раза в минуту.
if resp != nil && resp.StatusCode == http.StatusNotFound {
a.c.Errorf("agent: core отклонил ноду до подключения (неверный ключ, просроченная или использованная ссылка, часы ноды сильно расходятся)")
return true
}
if errors.Is(err, errPin) {
a.c.Errorf("agent: %v", errPin)
} else {
a.c.Errorf("agent: %v", err)
}
return false
}
defer ws.CloseNow()
defer a.setLink(nil)
ws.SetReadLimit(maxPayload)
send := func(v any) {
if b, err := jsjson.Marshal(v); err == nil {
ws.Write(ctx, websocket.MessageText, b)
}
}
ready := false
for {
_, raw, err := ws.Read(ctx)
if err != nil {
code := websocket.CloseStatus(err)
if !ready && code == -1 && ctx.Err() == nil {
a.c.Errorf("agent: %v", err)
}
if code == closeAuthFailed {
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
}
if code == 4026 {
a.c.Errorf("agent: панель требует обновить ноду (install.sh → «Обновить»): %v", err)
}
return code == closeAuthFailed
}
v, err := jsjson.Decode(raw)
if err != nil {
return false
}
m, _ := v.(jsjson.Object)
switch t, _ := m.Get2("t").(string); {
case t == "hello":
nonce, ok := m.Get2("nonce").(string)
if !ok {
continue
}
auth := jsjson.Object{
{Key: "t", Val: "auth"}, {Key: "node_id", Val: a.c.NodeID},
{Key: "sig", Val: b64.EncodeToString(ed25519.Sign(a.nodeKey, authMsg(nonce, a.c.NodeID)))},
}
if s := a.secret(); s != "" {
auth = append(auth, jsjson.KV{Key: "join", Val: jsjson.Object{{Key: "secret", Val: s}, {Key: "pub", Val: a.nodePubRaw}}})
}
if a.c.Version != "" {
auth = append(auth, jsjson.KV{Key: "version", Val: a.c.Version})
}
send(auth)
case t == "ready":
ready = true
armDead()
a.setLink(&coreLink{send: send, done: ctx.Done(), pending: map[int64]chan reply{}})
if onReady != nil {
onReady()
}
if a.secret() != "" {
a.forgetJoinSecret()
a.c.Logf("agent: нода привязана к core")
}
a.c.Logf("agent: подключено к core (%s)", a.addr())
// Подтверждение — только когда адрес уже на диске: по нему панель решает, что
// старый сервер можно выключать.
if core, ok := m.Get2("core").(string); ok && a.learnCore(core) {
send(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: core}})
}
case t == "metrics_res" && ready:
a.deliver(m)
case t == "req" && ready:
id, ok := m.Get2("id").(float64)
if !ok || id != float64(int64(id)) {
continue
}
// Запросы независимы, как async-обработчики в Node: медленный /awg/… не держит очередь.
go func() {
r := a.execute(m)
send(jsjson.Object{{Key: "t", Val: "res"}, {Key: "id", Val: id}, {Key: "status", Val: r.status}, {Key: "data", Val: r.data}})
}()
}
}
}
// ── Метрики панели для Prometheus ноды ─────────────────────────────────────
func (a *Agent) setLink(l *coreLink) {
a.mu.Lock()
a.link = l
a.mu.Unlock()
}
// deliver — ответ core на запрос метрик.
func (a *Agent) deliver(m jsjson.Object) {
id, ok := m.Get2("id").(float64)
a.mu.Lock()
l := a.link
a.mu.Unlock()
if !ok || l == nil {
return
}
l.mu.Lock()
ch := l.pending[int64(id)]
delete(l.pending, int64(id))
l.mu.Unlock()
if ch == nil {
return
}
st, _ := m.Get2("status").(float64)
text, _ := m.Get2("text").(string)
ch <- reply{int(st), text}
}
// coreMetrics — экспозиция /metrics панели, как её отдал бы сам core.
func (a *Agent) coreMetrics(ctx context.Context) reply {
a.mu.Lock()
l := a.link
a.mu.Unlock()
if l == nil {
return reply{503, "core недоступен\n"}
}
ch := make(chan reply, 1)
l.mu.Lock()
l.seq++
id := l.seq
l.pending[id] = ch
l.mu.Unlock()
defer func() {
l.mu.Lock()
delete(l.pending, id)
l.mu.Unlock()
}()
l.send(jsjson.Object{{Key: "t", Val: "metrics"}, {Key: "id", Val: id}})
select {
case r := <-ch:
return r
case <-l.done:
return reply{503, "core недоступен\n"}
case <-ctx.Done():
return reply{504, "core не ответил\n"}
}
}
// tokenOK — тот же токен, что встроенный Prometheus предъявил бы самой панели: хэш в файле,
// перечитываем на каждый скрейп (раз в 15 с), чтобы переустановка не требовала перезапуска.
func (a *Agent) tokenOK(r *http.Request) bool {
given, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
raw, err := os.ReadFile(a.c.MonitorHash)
if !ok || !strings.HasPrefix(given, "awgm_") || err != nil {
return false
}
want, err := hex.DecodeString(strings.TrimSpace(string(raw)))
sum := sha256.Sum256([]byte(given))
return err == nil && len(want) == len(sum) && subtle.ConstantTimeCompare(sum[:], want) == 1
}
// serveMetrics — 127.0.0.1:AGENT_METRICS_PORT/metrics для Prometheus этой ноды: метрики всех
// серверов панели, полученные у core по уже открытому WSS. Наружу порт не открывается.
func (a *Agent) serveMetrics(ctx context.Context) {
mux := http.NewServeMux()
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
if !a.tokenOK(r) {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", 401)
return
}
cctx, cancel := context.WithTimeout(r.Context(), metricsTimeout)
defer cancel()
res := a.coreMetrics(cctx)
text, _ := res.data.(string)
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(res.status)
io.WriteString(w, text)
})
srv := &http.Server{Addr: "127.0.0.1:" + strconv.Itoa(a.c.MetricsPort), Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() { <-ctx.Done(); srv.Close() }()
a.c.Logf("agent: метрики панели для Prometheus — http://%s/metrics", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
a.c.Errorf("agent: метрики для Prometheus не подняты: %v", err)
}
}
func hostPort(host string, port int) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
host = "[" + host + "]"
}
return host + ":" + strconv.Itoa(port)
}
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
func (a *Agent) Run(ctx context.Context) error {
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr())
if a.c.MetricsPort > 0 {
go a.serveMetrics(ctx)
}
backoff := time.Second
for {
ready := false
authFailed := a.connectOnce(ctx, func() { backoff, ready = time.Second, true })
if !ready {
a.next()
}
wait := backoff
if authFailed {
wait = maxBackoff
}
backoff = min(backoff*2, maxBackoff)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
}
+471
View File
@@ -0,0 +1,471 @@
package agent
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
func TestPathAllowed(t *testing.T) {
for p, want := range map[any]bool{
"/health": true,
"/api/users": true,
"/api/users/a?x=1": true,
"/awg/upgrade": true,
"/healthz": false,
"/api": false,
"/etc/passwd": false,
"//evil.com/api/x": false,
`/\evil.com/api/x`: false,
"/api/../etc": false,
"/api/%2e%2E/etc": false,
"/api/./x": false,
"/api/a b": false,
"api/users": false,
42: false,
"/api/" + strings.Repeat("a", 2048): false,
} {
if _, got := pathAllowed(p); got != want {
t.Errorf("pathAllowed(%v) = %v, want %v", p, got, want)
}
}
}
func writeKey(t *testing.T, file string, priv ed25519.PrivateKey) {
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatal(err)
}
os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
}
type core struct {
srv *httptest.Server
frames chan jsjson.Object // всё, что прислал агент
script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)
}
func newCore(t *testing.T, script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)) *core {
c := &core{frames: make(chan jsjson.Object, 16), script: script}
c.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/node/v1" {
http.NotFound(w, r)
return
}
ws, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer ws.CloseNow()
ctx := r.Context()
read := func() jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
return nil
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
c.frames <- o
return o
}
c.script(ctx, ws, read)
}))
t.Cleanup(c.srv.Close)
return c
}
func (c *core) pin(t *testing.T) string {
p, err := spkiPin(c.srv.Certificate().Raw)
if err != nil {
t.Fatal(err)
}
return p
}
func (c *core) hostPort() (string, int) {
u, _ := url.Parse(c.srv.URL)
port, _ := strconv.Atoi(u.Port())
return u.Hostname(), port
}
func newAgent(t *testing.T, c *core, pin, ctrlURL string) (*Agent, string) {
dir := t.TempDir()
host, port := c.hostPort()
_, ik, _ := ed25519.GenerateKey(rand.Reader)
writeKey(t, filepath.Join(dir, "internal.key"), ik)
envFile := filepath.Join(dir, "cli.env")
os.WriteFile(envFile, []byte("CORE_HOST=x\nJOIN_SECRET=s3cret\nNODE_ID=7\n"), 0o600)
a, err := New(Config{
CoreHost: host, CorePort: port, CorePin: pin, NodeID: 7, Ctrl: ctrlURL,
NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"),
CLIEnvFile: envFile, JoinSecret: "s3cret", Version: "Beta 0.3.4",
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
return a, envFile
}
func send(ctx context.Context, ws *websocket.Conn, v any) {
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(v))
}
func TestHandshakeAndRequests(t *testing.T) {
var internalPub ed25519.PublicKey
ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if !iauth.Verify(internalPub, tok, time.Now()) {
w.WriteHeader(401)
io.WriteString(w, `{"error":"Неверная авторизация"}`)
return
}
body, _ := io.ReadAll(r.Body)
switch r.URL.Path {
case "/api/users":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
io.WriteString(w, `{"got":`+string(body)+`, "q":"`+r.URL.RawQuery+`"}`)
case "/health":
io.WriteString(w, "ok")
default:
w.WriteHeader(204)
}
}))
defer ctrl.Close()
results := make(chan jsjson.Object, 8)
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n0nce"}})
auth := read()
if auth == nil {
return
}
join, _ := auth.Get2("join").(jsjson.Object)
pubRaw, _ := b64.DecodeString(join.Get2("pub").(string))
sig, _ := b64.DecodeString(auth.Get2("sig").(string))
if !ed25519.Verify(pubRaw, authMsg("n0nce", 7), sig) {
ws.Close(4003, "unauthorized")
return
}
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 1}, {Key: "method", Val: "post"},
{Key: "path", Val: "/api/users?x=1"}, {Key: "body", Val: jsjson.Object{{Key: "name", Val: "alice"}}}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/etc/passwd"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/health"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 4}, {Key: "method", Val: "DELETE"}, {Key: "path", Val: "/api/x"}})
for range 4 {
if r := read(); r != nil {
results <- r
}
}
ws.Close(websocket.StatusNormalClosure, "")
})
a, envFile := newAgent(t, c, c.pin(t), ctrl.URL)
internalPub = a.internalKey.Public().(ed25519.PublicKey)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if a.connectOnce(ctx, nil) {
t.Fatal("authFailed on a good handshake")
}
auth := <-c.frames
want := `{"t":"auth","node_id":7,"sig":"` + auth.Get2("sig").(string) + `","join":{"secret":"s3cret","pub":"` + a.nodePubRaw + `"},"version":"Beta 0.3.4"}`
if got := string(jsjson.MustMarshal(auth)); got != want {
t.Errorf("auth frame:\n got %s\nwant %s", got, want)
}
byID := map[float64]string{}
for range 4 {
r := <-results
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
}
for id, want := range map[float64]string{
1: `{"t":"res","id":1,"status":201,"data":{"got":{"name":"alice"},"q":"x=1"}}`,
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
3: `{"t":"res","id":3,"status":200,"data":"ok"}`,
4: `{"t":"res","id":4,"status":204,"data":""}`,
} {
if byID[id] != want {
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
}
}
// Секрет одноразовый: после ready он исчезает из памяти и из cli.env.
if a.secret() != "" {
t.Error("join secret still in memory")
}
if raw, _ := os.ReadFile(envFile); strings.Contains(string(raw), "JOIN_SECRET") || !strings.Contains(string(raw), "NODE_ID=7") {
t.Errorf("cli.env after join: %q", raw)
}
}
func TestWrongPinSendsNothing(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
})
a, _ := newAgent(t, c, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "http://127.0.0.1:1")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if a.connectOnce(ctx, nil) {
t.Fatal("pin mismatch reported as auth failure")
}
select {
case f := <-c.frames:
t.Fatalf("agent sent %v to a core with a foreign certificate", f)
default:
}
}
func TestAuthRejected(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
ws.Close(4003, "unauthorized")
})
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if !a.connectOnce(ctx, nil) {
t.Fatal("4003 not reported as auth failure")
}
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "JOIN_SECRET=s3cret") {
t.Error("join secret dropped without a successful join")
}
}
func TestNodeKeyPersists(t *testing.T) {
file := filepath.Join(t.TempDir(), "node.key")
k1, err := loadNodeKey(file, t.Logf)
if err != nil {
t.Fatal(err)
}
k2, _ := loadNodeKey(file, t.Logf)
if !k1.Equal(k2) {
t.Fatal("node key regenerated")
}
if st, _ := os.Stat(file); st.Mode().Perm() != 0o600 {
t.Fatalf("node.key mode %v", st.Mode().Perm())
}
}
// Нода #0 — сервер, с которого переехала панель: 0 — значение, а не «не задан».
func TestConfigNodeZero(t *testing.T) {
t.Setenv("CORE_HOST", "203.0.113.7")
t.Setenv("CORE_PORT", "40500")
t.Setenv("CORE_PIN", "pin")
for id, ok := range map[string]bool{"0": true, "3": true, "": false, "-1": false, "x": false} {
t.Setenv("NODE_ID", id)
c, err := ConfigFromEnv()
if (err == nil) != ok {
t.Errorf("NODE_ID=%q: %v", id, err)
}
if ok && strconv.Itoa(c.NodeID) != id {
t.Errorf("NODE_ID=%q → %d", id, c.NodeID)
}
}
}
// Панель переехала: core сообщает свой адрес в ready, агент записывает его в cli.env,
// подтверждает (core_ok) и с тех пор звонит туда первым, а адрес из join — запасной.
func TestLearnsCoreAddress(t *testing.T) {
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}, {Key: "core", Val: "198.51.100.9:40500"}})
read()
ws.Close(websocket.StatusNormalClosure, "")
})
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
host, port := c.hostPort()
join := hostPort(host, port)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
a.connectOnce(ctx, nil)
<-c.frames // auth
if ok := <-c.frames; string(jsjson.MustMarshal(ok)) != `{"t":"core_ok","core":"198.51.100.9:40500"}` {
t.Errorf("подтверждение: %s", jsjson.MustMarshal(ok))
}
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "CORE_ENDPOINT=198.51.100.9:40500") {
t.Errorf("cli.env: %q", raw)
}
if strings.Join(a.addrs, " ") != "198.51.100.9:40500 "+join || a.addr() != join {
t.Errorf("адреса %v, текущий %s", a.addrs, a.addr())
}
}
func TestBadCoreAddressIgnored(t *testing.T) {
a := &Agent{c: Config{CoreHost: "203.0.113.1", CorePort: 40500, CLIEnvFile: filepath.Join(t.TempDir(), "cli.env"), Logf: t.Logf, Errorf: t.Logf}}
a.setAddrs("")
for _, bad := range []string{"", "x", "host:0", "host:99999", "a b:1", "host:1/x", "[::1]:x"} {
if a.learnCore(bad) {
t.Errorf("принят адрес %q", bad)
}
}
if !a.learnCore("[2001:db8::1]:443") || a.addrs[0] != "[2001:db8::1]:443" {
t.Errorf("IPv6: %v", a.addrs)
}
}
// Записанный адрес не отвечает (старый сервер выключен или новый ещё не открыт) — следующая
// попытка идёт на адрес из join.
func TestFallsBackToJoinAddress(t *testing.T) {
connected := make(chan struct{}, 1)
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
read()
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
connected <- struct{}{}
<-ctx.Done()
})
a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
a.c.CoreEndpoint = "127.0.0.1:1"
a.setAddrs(a.c.CoreEndpoint)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go a.Run(ctx)
select {
case <-connected:
case <-ctx.Done():
t.Fatal("агент не перешёл на адрес из join")
}
}
func TestPromAllowed(t *testing.T) {
for p, want := range map[string]string{
"/prom/-/ready": "/-/ready",
"/prom/api/v1/query?query=up&time=1": "/api/v1/query?query=up&time=1",
"/prom/api/v1/query_range?query=up": "/api/v1/query_range?query=up",
"/prom/api/v1/admin/tsdb/delete_series": "",
"/prom/api/v1/query/../admin": "",
"/prom//api/v1/query": "",
"/prom/api/v1/query?query=up#x": "",
"/prom/api/v1/query?query=up\nHost: x": "",
"/prom/api/v1/labels": "",
} {
got, ok := promAllowed("GET", p)
if want == "" && ok || want != "" && got != want {
t.Errorf("promAllowed(%q) = %q, %v", p, got, ok)
}
}
if _, ok := promAllowed("POST", "/prom/api/v1/query"); ok {
t.Error("POST к Prometheus пропущен")
}
a := &Agent{c: Config{PromURL: "http://127.0.0.1:1"}}
if r := a.execProm("GET", "/prom/-/ready"); r.status != 404 {
t.Errorf("без AGENT_METRICS_PORT: %d", r.status)
}
}
// Prometheus на ноде: запросы панели доходят до него, а его скрейп получает метрики core.
func TestPrometheusOnNode(t *testing.T) {
prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/query":
io.WriteString(w, `{"status":"success","q":"`+r.URL.Query().Get("query")+`"}`)
case "/-/ready":
io.WriteString(w, "Prometheus Server is Ready.\n")
default:
w.WriteHeader(500)
}
}))
defer prom.Close()
results := make(chan jsjson.Object, 8)
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
if read() == nil {
return
}
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 1}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/query?query=up"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/admin/tsdb/snapshot"}})
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/-/ready"}})
for {
m := read()
if m == nil {
return
}
switch m.Get2("t") {
case "res":
results <- m
case "metrics":
send(ctx, ws, jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: m.Get2("id")}, {Key: "status", Val: 200}, {Key: "text", Val: "awg_up 1\n"}})
}
}
})
l, _ := net.Listen("tcp", "127.0.0.1:0")
port := l.Addr().(*net.TCPAddr).Port
l.Close()
a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
a.c.PromURL, a.c.MetricsPort = prom.URL, port
a.c.MonitorHash = filepath.Join(t.TempDir(), "monitor_token.sha256")
sum := sha256.Sum256([]byte("awgm_good"))
os.WriteFile(a.c.MonitorHash, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go a.Run(ctx)
byID := map[float64]string{}
for range 3 {
select {
case r := <-results:
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
case <-ctx.Done():
t.Fatal("нет ответов агента")
}
}
for id, want := range map[float64]string{
1: `{"t":"res","id":1,"status":200,"data":{"status":"success","q":"up"}}`,
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
3: `{"t":"res","id":3,"status":200,"data":"Prometheus Server is Ready.\n"}`,
} {
if byID[id] != want {
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
}
}
scrape := func(tok string) (int, string) {
req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(port)+"/metrics", nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
if st, _ := scrape("awgm_bad"); st != 401 {
t.Errorf("чужой токен: %d", st)
}
if st, body := scrape("awgm_good"); st != 200 || body != "awg_up 1\n" {
t.Errorf("скрейп: %d %q", st, body)
}
}
+706
View File
@@ -0,0 +1,706 @@
// Package cli — менеджер процессов Forgetting (порт cli/src): start/stop/restart/status,
// логин и пароль панели, join/unjoin ноды, пиры подписки, встроенный Prometheus и
// интерактивное меню. Сервисы — тот же бинарник с ролью: `awg-control ctrl|ui|agent`.
package cli
import (
"bufio"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson"
)
// ── Цвета ───────────────────────────────────────────────────────────────────
const (
cReset = "\x1b[0m"
cBold = "\x1b[1m"
cDim = "\x1b[2m"
cGreen = "\x1b[32m"
cGrey = "\x1b[90m"
cBlue = "\x1b[34m"
)
func bold(s string) string { return cBold + s + cReset }
func green(s string) string { return cGreen + s + cReset }
func grey(s string) string { return cGrey + s + cReset }
func dim(s string) string { return cDim + s + cReset }
func errorf(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
// ── Окружение ───────────────────────────────────────────────────────────────
// findRoot — корень установки: AWG_ROOT, иначе ближайший предок каталога бинарника, где есть
// cli/ или .env, иначе /opt/awg-control.
func findRoot() string {
if r := os.Getenv("AWG_ROOT"); r != "" {
return r
}
if exe, err := os.Executable(); err == nil {
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
for d := filepath.Dir(exe); d != "/" && d != "."; d = filepath.Dir(d) {
if exists(filepath.Join(d, "cli")) || exists(filepath.Join(d, ".env")) {
return d
}
}
}
return "/opt/awg-control"
}
func exists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
// loadEnvFile — уже заданное окружение не перетираем (??= в Node), поэтому порядок =
// приоритет: реальное окружение → cli.env → .env в корне проекта.
func loadEnvFile(file string) {
raw, err := os.ReadFile(file)
if err != nil {
return
}
for _, line := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
i := strings.Index(t, "=")
if i < 0 {
continue
}
k := strings.TrimSpace(t[:i])
if _, set := os.LookupEnv(k); !set {
os.Setenv(k, strings.TrimSpace(t[i+1:]))
}
}
}
func env(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
// envOr — `process.env.X || def`.
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
type service struct {
name string // ctrl | ui | agent — роль бинарника
label string
cwd string
legacy string // entry Node-версии: её процессы тоже узнаём (переход с TS)
env [][2]string
pidFile string
logFile string
}
type CLI struct {
root string
logs string
envFile string
origEnv []string // окружение до чтения cli.env и .env — для свежего перезапуска
exe string
brand, channel, version string
svc map[string]*service
all []string
isNode bool
in *bufio.Reader
}
func New() *CLI {
c := &CLI{root: findRoot(), origEnv: os.Environ(), in: bufio.NewReader(os.Stdin)}
c.exe, _ = os.Executable()
c.logs = filepath.Join(c.root, "logs")
os.MkdirAll(c.logs, 0o755)
c.envFile = filepath.Join(c.root, "cli", "cli.env")
loadEnvFile(c.envFile) // машинные настройки
loadEnvFile(filepath.Join(c.root, ".env")) // имя панели и версия
c.brand = envOr("BRAND", "Forgetting")
c.channel = envOr("CHANNEL", "Beta")
c.version = envOr("VERSION", "")
cliEnv := c.envFile
coreHost := os.Getenv("CORE_HOST")
audit := "off"
if coreHost == "" {
audit = env("AUDIT", "on")
}
c.svc = map[string]*service{
"awgctrl": {
name: "ctrl", label: "awg-ctrl", cwd: filepath.Join(c.root, "awg-ctrl"), legacy: "index.ts",
env: [][2]string{
{"PORT", env("AWGCTRL_PORT", "3005")},
{"SERVER_IP", env("SERVER_IP", "")},
{"SERVER_PORT", env("SERVER_PORT", "47619")},
{"SERVER_NAME", env("SERVER_NAME", "VPN")},
// Публичный ключ внутренней авторизации (awg-ctrl проверяет им токены awg-ui).
{"INTERNAL_AUTH_PUB_FILE", env("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")},
},
pidFile: "/tmp/awg-ctrl.pid", logFile: filepath.Join(c.logs, "awg-ctrl.log"),
},
"ui": {
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
env: [][2]string{
{"PORT", env("UI_PORT", "8080")},
// Секретный путь панели; _MOVED — путь панели, переехавшей сюда (принимаются оба).
{"UI_PREFIX", env("UI_PREFIX", "")},
{"UI_PREFIX_MOVED", env("UI_PREFIX_MOVED", "")},
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
{"BRAND", c.brand}, {"CHANNEL", c.channel}, {"VERSION", c.version},
{"UI_USER", env("UI_USER", "admin")},
{"UI_PASS", env("UI_PASS", "")},
{"JWT_SECRET", env("JWT_SECRET", "")},
// ui.db в каталоге данных AWG (вне проекта) — переживает переустановку.
{"UI_DB_FILE", env("UI_DB_FILE", "/etc/amnezia/amneziawg/ui.db")},
// Подписка sen://: без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
{"SUB_PORT", env("SUB_PORT", "")},
{"SUB_TLS", env("SUB_TLS", "off")},
{"SUB_SIGN_KEY_FILE", env("SUB_SIGN_KEY_FILE", "/etc/amnezia/amneziawg/sub_sign.key")},
{"SUB_TLS_KEY_FILE", env("SUB_TLS_KEY_FILE", "/etc/amnezia/amneziawg/sub_tls.key")},
{"SUB_TLS_CERT_FILE", env("SUB_TLS_CERT_FILE", "/etc/amnezia/amneziawg/sub_tls.crt")},
{"SUB_HOST", env("SUB_HOST", "")},
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
{"NODE_PORT", env("NODE_PORT", "")},
// Хаб: пускать ли агентов без подписи до апгрейда (on/off; пусто — пока не обновлены все).
{"HUB_LEGACY", env("HUB_LEGACY", "")},
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
{"RELAY_FROM", env("RELAY_FROM", "")},
// Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP.
{"TRUST_PROXY", env("TRUST_PROXY", "")},
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
{"CLI_ENV_FILE", cliEnv},
// Часовой аудит пиров на всех серверах — только у core.
{"AUDIT", audit},
{"METRICS_PEERS", env("METRICS_PEERS", "off")},
{"LOCAL_NODE", env("LOCAL_NODE", "on")},
{"NODE_TLS_KEY_FILE", env("NODE_TLS_KEY_FILE", "/etc/amnezia/amneziawg/node_tls.key")},
{"NODE_TLS_CERT_FILE", env("NODE_TLS_CERT_FILE", "/etc/amnezia/amneziawg/node_tls.crt")},
},
pidFile: "/tmp/awg-ui.pid", logFile: filepath.Join(c.logs, "awg-ui.log"),
},
// Агент ноды: сам звонит в core и исполняет запросы на локальном awg-ctrl.
"agent": {
name: "agent", label: "awg-agent", cwd: filepath.Join(c.root, "awg-agent"), legacy: "index.ts",
env: [][2]string{
{"CORE_HOST", env("CORE_HOST", "")},
{"CORE_PORT", env("CORE_PORT", "")},
{"CORE_ENDPOINT", env("CORE_ENDPOINT", "")},
{"CORE_PIN", env("CORE_PIN", "")},
{"NODE_ID", env("NODE_ID", "")},
{"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")},
{"JOIN_SECRET", env("JOIN_SECRET", "")},
{"CLI_ENV_FILE", cliEnv},
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
// Prometheus на ноде (`monitor install`): здесь он скрейпит метрики панели.
{"AGENT_METRICS_PORT", env("AGENT_METRICS_PORT", "")},
},
pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"),
},
// Ретранслятор на сервере, с которого переехала панель: прежние порты → новая панель.
"relay": {
name: "relay", label: "awg-relay", cwd: c.root,
env: [][2]string{{"RELAY", env("RELAY", "")}},
pidFile: "/tmp/awg-relay.pid", logFile: filepath.Join(c.logs, "awg-relay.log"),
},
}
// Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с
// LOCAL_NODE=off — панель без своего VPN; иначе — обычная установка. Нода со своей панелью
// (join без --no-panel) держит и awg-ui: обе панели ходят в один awg-ctrl.
c.isNode = coreHost != ""
noLocal := strings.ToLower(env("LOCAL_NODE", "on")) == "off"
nodePanel := c.isNode && os.Getenv("UI_PASS") != "" && strings.ToLower(env("NODE_PANEL", "on")) != "off"
switch {
case c.isNode && nodePanel:
c.all = []string{"awgctrl", "ui", "agent"}
case c.isNode:
c.all = []string{"awgctrl", "agent"}
case noLocal:
c.all = []string{"ui"}
default:
c.all = []string{"awgctrl", "ui"}
}
// Панель переехала (`migrate export`): здесь её больше не запускаем — вторая копия ui.db
// разошлась бы с перенесённой.
if c.panelMoved() {
c.all = without(c.all, "ui")
}
if os.Getenv("RELAY") != "" {
c.all = append(c.all, "relay")
}
return c
}
// setSvcEnv — значение, записанное в cli.env уже после запуска CLI, для следующего start.
func (c *CLI) setSvcEnv(name, key, value string) {
s := c.svc[name]
for i := range s.env {
if s.env[i][0] == key {
s.env[i][1] = value
return
}
}
s.env = append(s.env, [2]string{key, value})
}
func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" }
func without(s []string, v string) []string {
var out []string
for _, x := range s {
if x != v {
out = append(out, x)
}
}
return out
}
func (c *CLI) envOf(name string) string {
for _, kv := range c.svc[name].env {
if kv[0] == "PORT" {
return kv[1]
}
}
return ""
}
func (c *CLI) svcEnv(name, key string) string {
for _, kv := range c.svc[name].env {
if kv[0] == key {
return kv[1]
}
}
return ""
}
// ── Процессы ────────────────────────────────────────────────────────────────
func (c *CLI) readPid(name string) int {
raw, err := os.ReadFile(c.svc[name].pidFile)
if err != nil {
return 0
}
n, err := strconv.Atoi(firstInt(strings.TrimSpace(string(raw))))
if err != nil {
return 0
}
return n
}
var reLeadInt = regexp.MustCompile(`^[+-]?\d+`)
// firstInt — parseInt: ведущее целое, остальное игнорируется.
func firstInt(s string) string { return reLeadInt.FindString(s) }
// isAlive — EPERM: процесс есть, просто не наш (считаем живым).
func isAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
// pidMatches — защита от переиспользования PID: сверяем cmdline с сервисом. Узнаём и Go-сервис
// (`awg-control <роль>`), и процесс Node-версии (entry-файл в cmdline). Без procfs проверить нечем.
func (c *CLI) pidMatches(name string, pid int) bool {
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
return true
}
args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
s := c.svc[name]
if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name {
return true
}
if s.legacy == "" {
return false
}
// tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent.
return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd))
}
func (c *CLI) isRunning(name string) bool {
pid := c.readPid(name)
return pid != 0 && isAlive(pid) && c.pidMatches(name, pid)
}
func (c *CLI) start(name string) {
s := c.svc[name]
if c.isRunning(name) {
fmt.Printf(" %s: already running pid=%d\n", s.label, c.readPid(name))
return
}
if name == "awgctrl" {
if f := c.svcEnv("awgctrl", "INTERNAL_AUTH_PUB_FILE"); !exists(f) {
errorf(" awgctrl: публичный ключ внутренней авторизации не найден: %s", f)
os.Exit(1)
}
}
logF, err := os.OpenFile(s.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
errorf(" %s: не удалось запустить — %v", s.label, err)
return
}
defer logF.Close() // дескриптор унаследован ребёнком — родителю не нужен
cmd := exec.Command(c.exe, s.name)
cmd.Dir = s.cwd
if !exists(s.cwd) {
cmd.Dir = c.root
}
cmd.Env = os.Environ()
for _, kv := range s.env {
cmd.Env = append(cmd.Env, kv[0]+"="+kv[1])
}
cmd.Stdout, cmd.Stderr = logF, logF
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // detached: переживает CLI
if err := cmd.Start(); err != nil {
errorf(" %s: не удалось запустить — %v", s.label, err)
os.Remove(s.pidFile)
return
}
go cmd.Wait() // пока CLI жив (меню), не оставляем зомби
os.WriteFile(s.pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644)
fmt.Printf(" %s %s: started pid=%d\n", green("▶"), s.label, cmd.Process.Pid)
}
func (c *CLI) stop(name string) {
s := c.svc[name]
pid := c.readPid(name)
if pid == 0 || !isAlive(pid) || !c.pidMatches(name, pid) {
fmt.Printf(" %s: not running\n", s.label)
os.Remove(s.pidFile)
return
}
syscall.Kill(pid, syscall.SIGTERM)
// Ждём мягкого завершения до 5 с, иначе добиваем SIGKILL.
deadline := time.Now().Add(5 * time.Second)
for isAlive(pid) && !isZombie(pid) && time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
}
if isAlive(pid) && !isZombie(pid) {
syscall.Kill(pid, syscall.SIGKILL)
time.Sleep(200 * time.Millisecond)
}
os.Remove(s.pidFile)
fmt.Printf(" %s %s: stopped pid=%d\n", grey("■"), s.label, pid)
}
// isZombie — процесс завершился, но его ещё не забрал родитель (наш же Wait в меню).
func isZombie(pid int) bool {
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return false
}
s := string(raw)
if i := strings.LastIndex(s, ")"); i >= 0 && i+2 < len(s) {
return s[i+2] == 'Z'
}
return false
}
// fetchHealth — GET /health и разбор JSON. Любая ошибка — nil.
func fetchHealth(url string) any {
cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get(url)
if err != nil {
return nil
}
defer resp.Body.Close()
var b strings.Builder
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
b.WriteString(string(buf[:n]))
if err != nil {
break
}
}
v, err := jsjson.Decode([]byte(b.String()))
if err != nil {
return nil
}
return v
}
func get(v any, keys ...string) any {
for _, k := range keys {
o, ok := v.(jsjson.Object)
if !ok {
return nil
}
v = o.Get2(k)
}
return v
}
func strOr(v any, def string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return def
}
func (c *CLI) serviceLine(name string, menu bool) string {
running := c.isRunning(name)
dot, info := grey("○"), grey("stopped")
if running {
dot = green("●")
if menu {
info = green("running") + dim(fmt.Sprintf(" pid %d", c.readPid(name)))
} else {
info = green("running") + fmt.Sprintf(" pid %d", c.readPid(name))
}
}
return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info)
}
// panelURL — адрес панели с секретным путём; без UI_PORT (нода) — пусто.
func panelURL() string {
port := env("UI_PORT", "")
if port == "" {
return ""
}
host := env("SUB_HOST", env("SERVER_IP", "IP"))
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
u := "http://" + host + ":" + port + "/"
if p := env("UI_PREFIX", ""); p != "" {
u += p + "/"
}
return u
}
func (c *CLI) status() {
fmt.Println()
for _, n := range c.all {
fmt.Println(c.serviceLine(n, false))
}
if contains(c.all, "ui") {
if u := panelURL(); u != "" {
fmt.Println(" " + dim("Панель "+u))
}
}
if c.isRunning("awgctrl") {
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
if gen, ok := get(h, "gen").(string); ok && gen != "" {
g := "2.0"
if gen == "3.1" {
g = "3.1"
}
fmt.Println(" " + dim(fmt.Sprintf("AmneziaWG %s · модуль %s · tools %s", g, strOr(get(h, "awg", "module"), "?"), strOr(get(h, "awg", "tools"), "?"))))
}
}
fmt.Println()
}
// probe — любой HTTP-ответ (даже 401/503) значит, что порт слушается.
func probe(url string) bool {
cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get(url)
if err != nil {
return false
}
resp.Body.Close()
return true
}
// waitForHealth — ждём, пока awg-ctrl начнёт отвечать; умер — сразу выходим.
func (c *CLI) waitForHealth() bool {
url := "http://127.0.0.1:" + c.envOf("awgctrl") + "/health"
for range 10 {
if !c.isRunning("awgctrl") {
return false
}
if probe(url) {
return true
}
time.Sleep(300 * time.Millisecond)
}
return false
}
func (c *CLI) startAll() {
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
if !contains(c.all, "awgctrl") {
for _, n := range c.all {
c.start(n)
}
return
}
// Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN.
if contains(c.all, "relay") {
c.start("relay")
}
c.start("awgctrl")
var rest, labels []string
for _, n := range c.all {
if n != "awgctrl" && n != "relay" {
rest = append(rest, n)
labels = append(labels, c.svc[n].label)
}
}
if !c.isRunning("awgctrl") || !c.waitForHealth() {
errorf(" %s awg-ctrl не отвечает — %s не запущен (см. %s)", grey("■"), strings.Join(labels, ", "), c.svc["awgctrl"].logFile)
return
}
for _, n := range rest {
c.start(n)
}
}
func (c *CLI) stopAll() {
for _, n := range c.all {
c.stop(n)
}
}
func (c *CLI) restartAll() {
c.stopAll()
c.startAll()
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ── cli.env ─────────────────────────────────────────────────────────────────
func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) }
func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
func (c *CLI) readEnvFile() string {
raw, _ := os.ReadFile(c.envFile)
return string(raw)
}
// writeEnvFile — новый файл с правами 0600 (в нём пароль панели); у существующего права не трогаем.
func (c *CLI) writeEnvFile(content string) error {
return os.WriteFile(c.envFile, []byte(content), 0o600)
}
// restartFresh — роль читается из окружения при старте процесса, поэтому после смены cli.env
// перезапуск — свежим процессом с исходным окружением (без значений из старого cli.env).
func (c *CLI) restartFresh() {
cmd := exec.Command(c.exe, "restart")
cmd.Env = c.origEnv
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Run()
}
// ── Ввод ────────────────────────────────────────────────────────────────────
func (c *CLI) ask(prompt string) string {
fmt.Print(prompt)
line, _ := c.in.ReadString('\n')
return strings.TrimRight(line, "\r\n")
}
var reYes = regexp.MustCompile(`(?i)^y`)
func (c *CLI) confirmYes(q string) bool {
return reYes.MatchString(strings.TrimSpace(c.ask(q + " [y/N] ")))
}
// ── Точка входа ─────────────────────────────────────────────────────────────
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>|update|reinstall|uninstall|panel [path new|off|<путь>]|log [seen] [поиск] [-n N]|metrics [token|revoke]|hub [legacy on|off|auto]|check>"
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall", "panel", "log", "metrics", "hub", "check"}
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
func Main(args []string) {
c := New()
if len(args) == 0 {
c.interactiveMenu()
return
}
arg := func(i int) (string, bool) {
if i < len(args) {
return args[i], true
}
return "", false
}
sub, hasSub := arg(1)
a2, hasA2 := arg(2)
switch args[0] {
case "start":
c.startAll()
case "stop":
c.stopAll()
case "restart":
c.restartAll()
case "status":
c.status()
case "credentials":
switch sub {
case "user", "pass":
c.setCredentials(sub)
default:
c.setCredentials("")
}
case "join":
c.joinCore(sub, hasSub, a2, hasA2)
case "unjoin":
c.unjoinCore()
case "rehome":
c.rehome(sub, hasSub)
case "migrate":
c.migrateCmd(sub, a2, hasA2)
case "peers":
c.peersCmd(sub, hasSub, a2, hasA2)
case "monitor":
c.monitorCmd(sub)
case "panel":
c.panelCmd(args[1:])
case "log":
c.logCmd(args[1:])
case "metrics":
c.metricsCmd(args[1:])
case "hub":
c.hubCmd(args[1:])
case "check":
c.checkCmd()
case "update", "reinstall", "uninstall":
// Из командной строки — без своего вопроса: установщик сам спросит, что нужно.
c.runInstaller(args[0], false)
default:
fmt.Println(usage)
os.Exit(1)
}
}
+44
View File
@@ -0,0 +1,44 @@
package cli
import "testing"
func TestUpdateEnvVar(t *testing.T) {
for _, c := range []struct{ in, key, val, want string }{
{"", "A", "1", "\nA=1\n"},
{"A=0\nB=2\n", "A", "1", "A=1\nB=2\n"},
{"B=2\n\n\n", "A", "1", "B=2\nA=1\n"},
{"XA=0\nA=0\n", "A", "$&1", "XA=0\nA=$&1\n"},
{"A=0\nA=9\n", "A", "1", "A=1\nA=9\n"}, // как String.replace без g — только первое
} {
if got := updateEnvVar(c.in, c.key, c.val); got != c.want {
t.Errorf("updateEnvVar(%q, %s, %s) = %q, want %q", c.in, c.key, c.val, got, c.want)
}
}
if got := removeEnvVar("A=1\nJOIN_SECRET=x\nB=2\n", "JOIN_SECRET"); got != "A=1\nB=2\n" {
t.Errorf("removeEnvVar: %q", got)
}
}
func TestAreaOf(t *testing.T) {
for owner, want := range map[string]string{
"abcdef123456/m1/d2": "abcdef123456",
"m1/d2": "legacy",
"alice": "",
"ABCDEF123456/m1/d2": "",
"abc/m1/d2": "",
"": "",
} {
if got := areaOf(owner); got != want {
t.Errorf("areaOf(%q) = %q, want %q", owner, got, want)
}
}
}
func TestPeersWord(t *testing.T) {
for n, want := range map[int]string{0: "пиров", 1: "пир", 2: "пира", 4: "пира", 5: "пиров", 11: "пиров", 12: "пиров",
21: "пир", 22: "пира", 101: "пир", 111: "пиров", 114: "пиров", 1004: "пира"} {
if got := peersWord(n); got != want {
t.Errorf("peersWord(%d) = %s, want %s", n, got, want)
}
}
}
+552
View File
@@ -0,0 +1,552 @@
package cli
import (
"bytes"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
var b64url = base64.RawURLEncoding
// fromB64url — Buffer.from(s, "base64url"): нестрого, как в Node.
func fromB64url(s string) []byte {
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
if len(s)%4 == 1 {
s = s[:len(s)-1]
}
b, _ := b64url.DecodeString(s)
return b
}
// ── join / unjoin ───────────────────────────────────────────────────────────
var (
reJoinHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`)
reJoinPin = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
reJoinSec = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
)
func isPosInt(v any, max float64) (int64, bool) {
f, ok := v.(float64)
return int64(f), ok && f == float64(int64(f)) && f > 0 && f < max
}
// joinCore — `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как
// ноду. Рядом запускается агент; awg-ctrl и пользователи остаются как были. Своя панель
// продолжает работать, если она здесь есть, — с --no-panel она останавливается.
func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
if !hasLink || !strings.HasPrefix(link, "awgjoin://") || hasFlag && flag != "--no-panel" {
errorf(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)")
os.Exit(1)
}
v, err := jsjson.Decode(fromB64url(link[len("awgjoin://"):]))
j, isObj := v.(jsjson.Object)
if err != nil || !isObj {
errorf(" Ссылка повреждена")
os.Exit(1)
}
h, hok := j.Get2("h").(string)
pin, pinok := j.Get2("pin").(string)
sec, sok := j.Get2("s").(string)
port, pok := isPosInt(j.Get2("p"), 65536)
id, iok := isPosInt(j.Get2("n"), 9007199254740992)
// Нода #0 — этот же сервер после переноса панели (`migrate import` выдаёт строку с n = 0).
if n, ok := j.Get2("n").(float64); ok && n == 0 {
id, iok = 0, true
}
if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) ||
!iok || !sok || !reJoinSec.MatchString(sec) {
errorf(" Ссылка неверного формата или от другой версии панели")
os.Exit(1)
}
if c.isNode && !c.confirmYes(fmt.Sprintf(" Эта нода уже подключена к %s. Перепривязать?", os.Getenv("CORE_HOST"))) {
return
}
hasPanel := os.Getenv("UI_PASS") != ""
moved := c.panelMoved()
keepPanel := !hasFlag && hasPanel && !moved
content := c.readEnvFile()
np := "off"
if keepPanel {
np = "on"
}
for _, kv := range [][2]string{{"CORE_HOST", h}, {"CORE_PORT", fmt.Sprint(port)}, {"CORE_PIN", pin}, {"NODE_ID", fmt.Sprint(id)},
{"JOIN_SECRET", sec}, {"NODE_PANEL", np}} {
content = updateEnvVar(content, kv[0], kv[1])
}
content = removeEnvVar(content, "CORE_ENDPOINT") // адрес прежней панели к новой не относится
relay := ""
if moved {
relay = relayRoutes(h, port, j)
content = updateEnvVar(content, "RELAY", relay)
}
c.writeEnvFile(content)
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
// После переноса этими пирами управляет новая панель: снимать нечего.
dropPeers := !keepPanel && hasPanel && !moved && c.askDropPanelPeers()
if !keepPanel {
c.stop("ui")
}
if dropPeers {
c.dropArea(c.localPanelID())
}
tail := ""
if keepPanel {
tail = " · своя панель продолжает работать"
} else if hasPanel {
tail = " · своя панель остановлена"
}
fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail)
if relay != "" {
fmt.Println(" " + dim("Ретранслятор прежних портов на новую панель: "+relay))
}
c.restartFresh()
}
// relayRoutes — прежние порты этого сервера → новая панель. Подписка и хаб там на тех же
// портах (так их ставит `migrate import`), порт панели — из поля ui join-строки.
func relayRoutes(host string, hubPort int64, j jsjson.Object) string {
hp := func(p any) string { return net.JoinHostPort(host, fmt.Sprint(p)) }
var routes []string
add := func(local int, target string) {
if local > 0 {
routes = append(routes, fmt.Sprintf("%d=%s", local, target))
}
}
sub := envPort("SUB_PORT")
if p, ok := isPosInt(j.Get2("sub"), 65536); ok {
add(sub, hp(p))
} else {
add(sub, hp(sub))
}
add(envPort("NODE_PORT"), hp(hubPort))
if p, ok := isPosInt(j.Get2("ui"), 65536); ok {
add(envPort("UI_PORT"), hp(p))
}
return strings.Join(routes, ",")
}
func (c *CLI) unjoinCore() {
if !c.isNode {
fmt.Println(" Эта установка не подключена к core")
return
}
c.stop("agent")
content := c.readEnvFile()
for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL", "CORE_ENDPOINT"} {
content = removeEnvVar(content, k)
}
c.writeEnvFile(content)
fmt.Printf(" %s Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart\n", green("✓"))
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
if os.Getenv("UI_PASS") == "" {
fmt.Println(" " + dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials"))
}
}
// ── Пиры подписки (sen://) на локальном awg-ctrl ────────────────────────────
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели. Пиры
// без области («m1/d2») выданы до областей. Здесь — ручной рычаг для оператора: панель
// выключена или core пропал насовсем. По времени ничего не удаляется.
var (
reArea = regexp.MustCompile(`^[a-z0-9]{8,16}$`)
reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`)
)
func (c *CLI) localPanelID() string {
raw, err := os.ReadFile(filepath.Join(c.root, "awg-ui", "panel.id"))
if err != nil {
return ""
}
if id := strings.TrimSpace(string(raw)); reArea.MatchString(id) {
return id
}
return ""
}
type ctrlResp struct {
status int
data any
}
// ctrlCall — запрос к локальному awg-ctrl с тем же короткоживущим токеном, что у панели и агента.
func (c *CLI) ctrlCall(method, path string, body any) (*ctrlResp, error) {
key, err := iauth.LoadPrivate(c.svcEnv("ui", "INTERNAL_AUTH_KEY_FILE"))
if err != nil {
return nil, err
}
var rd io.Reader
if body != nil {
rd = bytes.NewReader(jsjson.MustMarshal(body))
}
req, err := http.NewRequest(method, "http://127.0.0.1:"+c.envOf("awgctrl")+path, rd)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+iauth.Mint(key, "awg-cli", time.Now()))
req.Header.Set("Content-Type", "application/json")
cl := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
data, err := jsjson.Decode(raw)
if err != nil {
data = nil
}
return &ctrlResp{resp.StatusCode, data}, nil
}
type ctrlPeer struct {
pub, ip, owner string
online bool
}
func ctrlError(r *ctrlResp) error {
if e, ok := get(r.data, "error").(string); ok {
return fmt.Errorf("%s", e)
}
return fmt.Errorf("awg-ctrl ответил %d", r.status)
}
func (c *CLI) listPeers() ([]ctrlPeer, error) {
r, err := c.ctrlCall("GET", "/api/peers", nil)
if err != nil {
return nil, err
}
if r.status != 200 {
return nil, ctrlError(r)
}
var out []ctrlPeer
list, _ := get(r.data, "peers").([]any)
for _, p := range list {
on, _ := get(p, "online").(bool)
out = append(out, ctrlPeer{pub: strOr(get(p, "pub_key"), ""), ip: strOr(get(p, "ip"), ""), owner: strOr(get(p, "owner"), ""), online: on})
}
return out, nil
}
// areaOf — область пира: id панели, «legacy» (до областей) или "" (чужой формат — не наш).
func areaOf(owner string) string {
head := strings.SplitN(owner, "/", 2)[0]
if strings.Contains(owner, "/") && reArea.MatchString(head) {
return head
}
if reLegacyOwner.MatchString(owner) {
return "legacy"
}
return ""
}
func peersWord(n int) string {
switch {
case n%10 == 1 && n%100 != 11:
return "пир"
case n%10 >= 2 && n%10 <= 4 && !(n%100 >= 12 && n%100 <= 14):
return "пира"
}
return "пиров"
}
func (c *CLI) askDropPanelPeers() bool {
own := c.localPanelID()
if own == "" {
return false
}
peers, err := c.listPeers()
if err != nil {
fmt.Fprintln(os.Stderr, " "+dim("Не удалось посмотреть пиров подписки: "+err.Error()))
return false
}
n := 0
for _, p := range peers {
if areaOf(p.owner) == own {
n++
}
}
if n == 0 {
return false
}
switch strings.ToLower(os.Getenv("NODE_PEERS")) {
case "keep":
return false
case "drop":
return true
}
fmt.Printf("\n На этой панели %d %s подписки (устройства мастер-ключей). Без панели ими некому управлять:\n", n, peersWord(n))
fmt.Println(" 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin")
fmt.Println(" 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)")
return strings.TrimSpace(c.ask(" Выбор [1]: ")) == "2"
}
// dropArea — снять всю область: для панели — пустым списком через sync, для legacy — по одному.
func (c *CLI) dropArea(area string) {
if area == "legacy" {
peers, err := c.listPeers()
if err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
n := 0
for _, p := range peers {
if areaOf(p.owner) != "legacy" {
continue
}
if _, err := c.ctrlCall("DELETE", "/api/peers/"+b64url.EncodeToString(stdB64(p.pub)), nil); err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
n++
}
fmt.Printf(" %s Снято пиров без области: %d\n", green("✓"), n)
return
}
r, err := c.ctrlCall("PUT", "/api/peers/sync", jsjson.Object{{Key: "owner", Val: area}, {Key: "keep", Val: []any{}}})
if err == nil && r.status != 200 {
err = ctrlError(r)
}
if err != nil {
errorf(" Не удалось снять пиров: %v", err)
return
}
fmt.Printf(" %s Снято пиров области %s: %s\n", green("✓"), area, jsString(get(r.data, "removed")))
}
func stdB64(s string) []byte {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return fromB64url(s)
}
return b
}
// jsString — `${v}` для вывода числа или строки из ответа.
func jsString(v any) string {
switch x := v.(type) {
case nil:
return "undefined"
case string:
return x
case float64:
return jsjson.Number(x)
}
return string(jsjson.MustMarshal(v))
}
func (c *CLI) peersCmd(sub string, hasSub bool, arg string, hasArg bool) {
own := c.localPanelID()
peers, err := c.listPeers()
if err != nil {
errorf(" awg-ctrl недоступен: %v", err)
os.Exit(1)
}
var order []string
areas := map[string][]ctrlPeer{}
for _, p := range peers {
if a := areaOf(p.owner); a != "" {
if _, seen := areas[a]; !seen {
order = append(order, a)
}
areas[a] = append(areas[a], p)
}
}
if !hasSub {
if len(areas) == 0 {
fmt.Println(" Пиров подписки (sen://) на этом сервере нет")
return
}
fmt.Print("\n Пиры подписки (sen://) на этом сервере:\n\n")
for _, a := range order {
list := areas[a]
mark := ""
if a == own {
mark = "эта панель"
} else if a == "legacy" {
mark = "без области (выданы до областей)"
}
online := 0
for _, p := range list {
if p.online {
online++
}
}
fmt.Printf(" %s %4d %-5s онлайн %d %s\n", bold(fmt.Sprintf("%-16s", a)), len(list), peersWord(len(list)), online, dim(mark))
}
fmt.Printf("\n %s\n", dim("Снять область целиком: awg-ctrl peers drop <область>"))
return
}
if sub != "drop" || !hasArg || arg == "" || !(arg == "legacy" || reArea.MatchString(arg)) {
errorf(" Usage: awg-ctrl peers [drop <область|legacy>]")
os.Exit(1)
}
n := len(areas[arg])
if n == 0 {
fmt.Printf(" В области %s пиров нет\n", arg)
return
}
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
if arg == own && c.isRunning("ui") {
errorf(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней")
os.Exit(1)
}
fmt.Println(" " + dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —"))
fmt.Println(" " + dim("сперва удалите сервер в ней (или мастер-ключи)."))
if !c.confirmYes(fmt.Sprintf(" Снять %d %s области %s? Их устройства потеряют доступ к этому серверу.", n, peersWord(n), arg)) {
return
}
c.dropArea(arg)
}
// ── Логин и пароль панели ───────────────────────────────────────────────────
func (c *CLI) setCredentials(field string) {
var user, pass *string
if field == "" || field == "user" {
cur := env("UI_USER", "admin")
u := strings.TrimSpace(c.ask(fmt.Sprintf("\n Логин [%s]: ", cur)))
if u == "" {
u = cur
}
user = &u
}
if field == "" || field == "pass" {
b := make([]byte, 9)
rand.Read(b)
generated := b64url.EncodeToString(b)[:12]
p := strings.TrimSpace(c.ask(fmt.Sprintf(" Пароль [%s]: ", generated)))
if p == "" {
p = generated
}
pass = &p
}
content := c.readEnvFile()
if user != nil {
content = updateEnvVar(content, "UI_USER", *user)
os.Setenv("UI_USER", *user)
}
if pass != nil {
content = updateEnvVar(content, "UI_PASS", *pass)
os.Setenv("UI_PASS", *pass)
}
c.writeEnvFile(content)
if user != nil {
fmt.Printf("\n %s Логин: %s\n", green("✓"), *user)
}
if pass != nil {
fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass)
}
if u := panelURL(); u != "" {
fmt.Printf(" %s Панель: %s\n", green("✓"), u)
}
fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу."))
}
// ── Интерактивное меню ──────────────────────────────────────────────────────
func clearScreen() {
if st, err := os.Stdout.Stat(); err == nil && st.Mode()&os.ModeCharDevice != 0 {
fmt.Print("\x1b[1;1H\x1b[0J")
}
}
func (c *CLI) interactiveMenu() {
printMenu := func() {
clearScreen()
var parts []string
for _, p := range []string{c.brand, c.channel, c.version} {
if p != "" {
parts = append(parts, p)
}
}
fmt.Printf("\n%s\n\n", bold(cBlue+"── "+strings.Join(parts, " ")+" ──"+cReset))
for _, n := range c.all {
fmt.Println(c.serviceLine(n, true))
}
sep := dim("──────────────")
fmt.Printf(`
%s Запустить
%s Остановить
%s Перезапустить
%s Статус
%s
%s Сменить логин UI
%s Сменить пароль UI
%s
%s Обновить
%s Переустановить
%s Удалить
%s
%s Адрес панели и секретный путь
%s Журнал подключений
%s Кто когда был в VPN
%s Проверка скрытности
%s
%s Выход
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep,
bold("10"), bold("11"), bold("12"), bold("13"), sep, bold("0"))
}
for {
printMenu()
fmt.Print(" › ")
choice, err := c.in.ReadString('\n')
if err != nil && choice == "" {
fmt.Println()
return // stdin закрыт
}
choice = strings.TrimSpace(choice)
fmt.Println()
switch choice {
case "1":
c.startAll()
case "2":
c.stopAll()
case "3":
c.restartAll()
case "4":
c.status()
case "5":
c.setCredentials("user")
case "6":
c.setCredentials("pass")
case "7":
c.runInstaller("update", true)
case "8":
c.runInstaller("reinstall", true)
case "9":
c.runInstaller("uninstall", true)
case "10":
c.panelMenu()
case "11":
c.logCmd(nil)
case "12":
c.logCmd([]string{"seen"})
case "13":
c.checkCmd()
case "0":
os.Exit(0)
default:
fmt.Println(grey(" Неверный выбор"))
}
c.ask("\n " + dim("Enter для продолжения..."))
}
}
+565
View File
@@ -0,0 +1,565 @@
package cli
// Команды для новых возможностей панели — то же, что в веб-интерфейсе, но с сервера:
// panel [path new|off|<путь>] — адрес панели, секретный путь (UI_PREFIX)
// log [seen] [поиск] [-n N] — журнал VPN-подключений этого сервера, «кто когда был»
// metrics [token|revoke] — токен «только метрики» для своего Prometheus
// hub [legacy on|off|auto] — хаб нод: подпись до апгрейда, кто из нод ещё старый
// check — что на этом сервере выдаёт его сканеру и DPI
import (
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/pem"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/migrate"
)
// pad — дополнение пробелами по числу символов, не байт: в Printf %-Ns кириллица съезжает.
func pad(s string, n int) string {
if l := len([]rune(s)); l < n {
return s + strings.Repeat(" ", n-l)
}
return s
}
func yellow(s string) string { return "\x1b[33m" + s + cReset }
func (c *CLI) hasPanel() bool { return contains(c.all, "ui") || env("UI_PORT", "") != "" && !c.isNode }
func (c *CLI) hasVPN() bool { return contains(c.all, "awgctrl") }
func (c *CLI) uiDB() (*sql.DB, error) {
f := c.svcEnv("ui", "UI_DB_FILE")
if !exists(f) {
return nil, fmt.Errorf("базы панели нет (%s)", f)
}
return migrate.OpenDB(f)
}
// restartUI — cli.env изменён: панель перезапускается с новым значением, VPN не трогаем.
func (c *CLI) restartUI(key, value string) {
c.setSvcEnv("ui", key, value)
if c.isRunning("ui") {
c.stop("ui")
c.start("ui")
}
}
func when(ts int64) string {
t := time.Unix(ts, 0)
now := time.Now()
switch {
case t.YearDay() == now.YearDay() && t.Year() == now.Year():
return "сегодня " + t.Format("15:04")
case t.Year() == now.Year():
return t.Format("02.01 15:04")
}
return t.Format("02.01.2006 15:04")
}
func dur(s int64) string {
switch {
case s < 60:
return fmt.Sprintf("%d сек", s)
case s < 3600:
return fmt.Sprintf("%d мин", s/60)
case s < 86400:
return fmt.Sprintf("%d ч %d мин", s/3600, s%3600/60)
}
return fmt.Sprintf("%d дн %d ч", s/86400, s%86400/3600)
}
func bytesStr(n float64) string {
switch {
case n < 1<<10:
return fmt.Sprintf("%.0f B", n)
case n < 1<<20:
return fmt.Sprintf("%.1f KB", n/(1<<10))
case n < 1<<30:
return fmt.Sprintf("%.1f MB", n/(1<<20))
}
return fmt.Sprintf("%.2f GB", n/(1<<30))
}
// ── panel ───────────────────────────────────────────────────────────────────
var rePrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
func randPrefix() string {
const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 16)
rand.Read(b)
for i := range b {
b[i] = abc[int(b[i])%len(abc)]
}
return string(b)
}
func (c *CLI) panelCmd(args []string) {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — это нода")
return
}
if len(args) == 0 {
c.showPanel()
return
}
if args[0] != "path" || len(args) < 2 {
errorf(" Usage: awg-ctrl panel [path new|off|<путь>]")
os.Exit(1)
}
c.setPanelPath(args[1])
}
func (c *CLI) showPanel() {
fmt.Printf("\n Панель %s\n", bold(panelURL()))
if p := env("UI_PREFIX", ""); p != "" {
fmt.Println(" " + dim("Секретный путь: снаружи без него — 404. Сменить: awg-ctrl panel path new"))
} else {
fmt.Println(" " + yellow("Без секретного пути: сканер узнаёт панель по странице логина.") + " " + dim("Включить: awg-ctrl panel path new"))
}
if m := env("UI_PREFIX_MOVED", ""); m != "" {
fmt.Println(" " + dim("Принимается и путь переехавшей панели: /"+m+"/"))
}
fmt.Println()
}
// setPanelPath — new (случайный), off (без пути) или свой. Закладки и адрес внешнего API
// со старым путём перестанут работать — предупреждаем.
func (c *CLI) setPanelPath(arg string) {
v := arg
switch arg {
case "new":
v = randPrefix()
case "off":
v = ""
default:
if !rePrefix.MatchString(arg) {
errorf(" Путь: 8–64 символа A–Z a–z 0–9 _ -, или new / off")
os.Exit(1)
}
}
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "UI_PREFIX", v)); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
os.Setenv("UI_PREFIX", v)
c.restartUI("UI_PREFIX", v)
fmt.Printf("\n %s Панель: %s\n", green("✓"), bold(panelURL()))
fmt.Println(" " + dim("Старый адрес больше не открывается: обновите закладки и адрес внешнего API и Prometheus."))
fmt.Println()
}
// ── log ─────────────────────────────────────────────────────────────────────
var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`)
// ownerName — «устройство · ключ» для пира sen://, если его панель — на этой машине.
func (c *CLI) ownerName(db *sql.DB, owner string) string {
m := reOwner.FindStringSubmatch(owner)
if m == nil || db == nil || m[1] != "" && m[1] != c.localPanelID() {
return owner
}
var label, dev string
db.QueryRow("SELECT label FROM master_keys WHERE id = ?", m[2]).Scan(&label)
db.QueryRow("SELECT device_name FROM devices WHERE id = ?", m[3]).Scan(&dev)
switch {
case dev != "" && label != "":
return dev + " · «" + label + "»"
case label != "":
return "удалённое устройство · «" + label + "»"
}
return "удалённое устройство"
}
func (c *CLI) logCmd(args []string) {
if !c.hasVPN() {
fmt.Println(" VPN на этой машине нет — журнал серверов во вкладке «Подключения» панели")
return
}
seen, limit, q := false, 50, ""
for i := 0; i < len(args); i++ {
switch a := args[i]; {
case a == "seen":
seen = true
case a == "-n" && i+1 < len(args):
n, err := strconv.Atoi(args[i+1])
if err != nil || n <= 0 {
errorf(" -n: число событий")
os.Exit(1)
}
limit, i = min(n, 500), i+1
default:
q = strings.TrimSpace(q + " " + a)
}
}
v := url.Values{"limit": {strconv.Itoa(limit)}}
if q != "" && !seen {
v.Set("q", q)
}
r, err := c.ctrlCall("GET", "/api/connlog?"+v.Encode(), nil)
if err != nil {
errorf(" awg-ctrl недоступен: %v", err)
os.Exit(1)
}
if r.status == 404 {
errorf(" awg-ctrl этой версии журнала не ведёт — обновите сервер: awg-ctrl update")
os.Exit(1)
}
if r.status != 200 {
errorf(" %v", ctrlError(r))
os.Exit(1)
}
db, _ := c.uiDB()
if db != nil {
defer db.Close()
}
who := func(o any) (string, string) {
name, _ := get(o, "name").(string)
ip, _ := get(o, "ip").(string)
if name == "" {
if owner, _ := get(o, "owner").(string); owner != "" {
name = c.ownerName(db, owner)
} else {
pub, _ := get(o, "pub_key").(string)
name = "ключ " + pub[:min(10, len(pub))] + "…"
}
}
return name, ip
}
fmt.Println()
if since, ok := get(r.data, "since").(float64); ok {
fmt.Printf(" %s\n\n", dim(fmt.Sprintf("Журнал ведётся с %s, хранится %v дней. Время отключения — последняя активность (±30 с).",
when(int64(since)), get(r.data, "keep_days"))))
}
if seen {
type row struct {
name, ip string
online bool
last int64
}
var rows []row
peers, _ := get(r.data, "peers").([]any)
for _, p := range peers {
name, ip := who(p)
if q != "" && !strings.Contains(strings.ToLower(name+" "+ip), strings.ToLower(q)) {
continue
}
last, _ := get(p, "last_seen").(float64)
online, _ := get(p, "online").(bool)
rows = append(rows, row{name, ip, online, int64(last)})
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].online != rows[j].online {
return rows[i].online
}
return rows[i].last > rows[j].last
})
for _, x := range rows {
state := dim("не подключался с начала журнала")
switch {
case x.online:
state = green("онлайн")
case x.last > 0:
state = "был " + when(x.last) + dim(" ("+dur(time.Now().Unix()-x.last)+" назад)")
}
fmt.Printf(" %s %s %s\n", pad(x.name, 32), dim(pad(x.ip, 12)), state)
}
if len(rows) == 0 {
fmt.Println(" " + dim("Никого"))
}
fmt.Println()
return
}
events, _ := get(r.data, "events").([]any)
if len(events) == 0 {
fmt.Println(" " + dim("Событий нет"))
}
// Старые сверху: читается как лента.
for i := len(events) - 1; i >= 0; i-- {
e := events[i]
at, _ := get(e, "at").(float64)
name, ip := who(e)
mark, tail := green("→ подключился"), ""
if get(e, "event") == "disconnect" {
mark = dim("← отключился ")
d, _ := get(e, "duration").(float64)
rx, _ := get(e, "rx").(float64)
tx, _ := get(e, "tx").(float64)
tail = dim(fmt.Sprintf(" %s · ↓ %s ↑ %s", dur(int64(d)), bytesStr(tx), bytesStr(rx)))
}
fmt.Printf(" %s %s %s %s%s\n", pad(when(int64(at)), 15), mark, name, dim(ip), tail)
}
fmt.Printf("\n %s\n\n", dim("Больше: awg-ctrl log -n 200 · по имени: awg-ctrl log <имя> · кто когда был: awg-ctrl log seen"))
}
// ── metrics ─────────────────────────────────────────────────────────────────
func (c *CLI) metricsCmd(args []string) {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — токен метрик выдаёт панель")
return
}
db, err := c.uiDB()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer db.Close()
sub := ""
if len(args) > 0 {
sub = args[0]
}
switch sub {
case "":
var prefix string
var created int64
var last sql.NullInt64
if db.QueryRow("SELECT prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&prefix, &created, &last) != nil {
fmt.Print("\n Токена для внешнего Prometheus нет. Выдать: awg-ctrl metrics token\n\n")
return
}
used := "ещё не было"
if last.Valid && last.Int64 > 0 {
used = when(last.Int64)
}
fmt.Printf("\n Токен %s · выдан %s · последний скрейп %s\n", bold(prefix), when(created), used)
fmt.Print(" " + dim("Перевыпустить: awg-ctrl metrics token · отозвать: awg-ctrl metrics revoke") + "\n\n")
case "token":
b := make([]byte, 24)
rand.Read(b)
tok := "awgm_" + b64url.EncodeToString(b)
h := sha256.Sum256([]byte(tok))
if _, err := db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)",
h[:], tok[:13]+"…", time.Now().Unix()); err != nil {
errorf(" ui.db: %v (панель этой версии ещё не создала таблицу — запустите её)", err)
os.Exit(1)
}
u, _ := url.Parse(panelURL())
fmt.Printf("\n %s Токен (показывается один раз, прежний больше не работает):\n\n %s\n\n", green("✓"), bold(tok))
fmt.Print(" Фрагмент prometheus.yml:\n\n")
fmt.Printf(" scrape_configs:\n - job_name: vpn-panel\n metrics_path: %smetrics\n authorization:\n credentials: %s\n static_configs:\n - targets: ['%s']\n\n",
u.Path, tok, u.Host)
fmt.Print(" " + dim("По голому HTTP токен и метрики идут открытым текстом — скрейпьте через nginx с TLS или по VPN.") + "\n\n")
case "revoke":
db.Exec("DELETE FROM metrics_token WHERE id = 1")
fmt.Printf(" %s Токен отозван\n", green("✓"))
default:
errorf(" Usage: awg-ctrl metrics [token|revoke]")
os.Exit(1)
}
}
// ── hub ─────────────────────────────────────────────────────────────────────
func (c *CLI) hubCmd(args []string) {
if !c.hasPanel() || env("NODE_PORT", "") == "" {
fmt.Println(" Хаб нод на этой машине не включён (NODE_PORT)")
return
}
if len(args) > 0 {
if args[0] != "legacy" || len(args) < 2 || !regexp.MustCompile(`^(on|off|auto)$`).MatchString(args[1]) {
errorf(" Usage: awg-ctrl hub [legacy on|off|auto]")
os.Exit(1)
}
v := args[1]
if v == "auto" {
v = ""
}
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "HUB_LEGACY", v)); err != nil {
errorf(" %v", err)
os.Exit(1)
}
os.Setenv("HUB_LEGACY", v)
c.restartUI("HUB_LEGACY", v)
fmt.Printf(" %s HUB_LEGACY=%s\n", green("✓"), args[1])
}
db, err := c.uiDB()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer db.Close()
rows, err := db.Query("SELECT id, name, COALESCE(version, ''), COALESCE(last_seen, 0), COALESCE(preauth, 0), pub IS NOT NULL FROM nodes ORDER BY id")
if err != nil {
errorf(" ui.db: %v", err)
os.Exit(1)
}
type node struct {
id int64
name, version string
seen int64
preauth, joined bool
}
var list []node
for rows.Next() {
var n node
if rows.Scan(&n.id, &n.name, &n.version, &n.seen, &n.preauth, &n.joined) == nil {
list = append(list, n)
}
}
rows.Close()
old := 0
fmt.Printf("\n Хаб нод :%s\n\n", env("NODE_PORT", ""))
for _, n := range list {
state := green(pad("с подписью", 28))
if !n.preauth {
state, old = yellow(pad("старый агент — без подписи", 28)), old+1
if !n.joined {
state = dim(pad("ещё не подключалась", 28))
}
}
seen := dim("не подключалась")
if n.seen > 0 {
seen = "был " + when(n.seen)
}
fmt.Printf(" #%-3d %s %s %s %s\n", n.id, pad(n.name, 20), dim(pad(n.version, 12)), state, seen)
}
if len(list) == 0 {
fmt.Println(" " + dim("Нод нет"))
}
mode := env("HUB_LEGACY", "")
fmt.Println()
switch {
case mode == "on":
fmt.Println(" " + yellow("HUB_LEGACY=on: хаб пускает агентов без подписи — сканер узнаёт его по /node/v1"))
case mode == "off":
fmt.Println(" HUB_LEGACY=off: только с подписью. Старые агенты не подключатся, пока ноду не обновят")
case old > 0:
fmt.Println(" " + yellow(fmt.Sprintf("Переходный режим: %d нод(ы) без подписи — хаб пускает и без неё. Обновите их: awg-ctrl update на ноде", old)))
default:
fmt.Println(" " + green("Все ноды с подписью — без неё хаб отвечает 404"))
}
fmt.Print(" " + dim("Принудительно: awg-ctrl hub legacy on|off|auto") + "\n\n")
}
// ── check ───────────────────────────────────────────────────────────────────
// Прежние общие параметры установок до рандомизации — по ним сервер узнаётся DPI.
var legacyAWG = map[string]string{"ListenPort": "47619", "S1": "90", "S2": "45", "H1": "1224800044-2116730834"}
func readConf(file string) map[string]string {
out := map[string]string{}
raw, err := os.ReadFile(file)
if err != nil {
return nil
}
for _, l := range strings.Split(strings.SplitN(string(raw), "[Peer]", 2)[0], "\n") {
if k, v, ok := strings.Cut(l, "="); ok {
out[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return out
}
func certCN(file string) string {
raw, err := os.ReadFile(file)
if err != nil {
return ""
}
blk, _ := pem.Decode(raw)
if blk == nil {
return ""
}
crt, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return ""
}
return crt.Subject.CommonName
}
// checkCmd — сводка: что на этом сервере выдаёт его сканеру (активное зондирование) и DPI.
func (c *CLI) checkCmd() {
ok := func(s string) { fmt.Println(" " + green("✓") + " " + s) }
bad := func(s, fix string) { fmt.Println(" " + yellow("⚠") + " " + s + "\n " + dim(fix)) }
fmt.Println()
if c.hasVPN() {
conf := readConf(filepath.Join(env("AWG_CONF_DIR", "/etc/amnezia/amneziawg"), "awg1.conf"))
switch {
case conf == nil:
bad("awg1.conf не прочитан", "запустите от root")
default:
var same []string
for k, v := range legacyAWG {
if conf[k] == v {
same = append(same, k)
}
}
sort.Strings(same)
if len(same) > 0 {
bad("VPN: общие для старых установок значения ("+strings.Join(same, ", ")+") — по ним DPI находит все такие серверы сразу",
"переустановка без сохранения данных (awg-ctrl reinstall) и раздача новых ключей")
} else {
ok("VPN: порт и параметры обфускации свои у этого сервера")
}
if conf["I1"] == "" {
bad("VPN: нет своего I1 — ключи получают общий пакет-маскировку", "awg-ctrl reinstall с сохранением данных выдаст свой — старые ключи продолжат работать")
}
}
}
if c.hasPanel() {
if env("UI_PREFIX", "") != "" {
ok("Панель: секретный путь — снаружи без него 404")
} else {
bad("Панель без секретного пути — сканер узнаёт её по странице логина", "awg-ctrl panel path new")
}
bad("Панель по голому HTTP: пути, JSON и токены видны DPI", "nginx с TLS перед панелью (README, «Панель за nginx») или вход по VPN")
if strings.ToLower(env("SUB_TLS", "off")) == "on" {
ok("Подписка sen:// по HTTPS")
} else {
bad("Подписка sen:// по HTTP: ответы подписаны, но видны DPI", "переустановка с SUB_TLS=on — ссылки придётся выдать заново")
}
if cn := certCN(c.svcEnv("ui", "SUB_TLS_CERT_FILE")); cn == "sen" {
bad("Сертификат подписки с общим именем «sen»", "имя меняется только с ключом — при переустановке без сохранения данных")
}
if env("NODE_PORT", "") != "" {
if cn := certCN(c.svcEnv("ui", "NODE_TLS_CERT_FILE")); cn == "awg-node" {
bad("Сертификат хаба с общим именем «awg-node»", "имя меняется только с ключом — ноды придётся подключить заново")
}
switch strings.ToLower(env("HUB_LEGACY", "")) {
case "on":
bad("Хаб пускает агентов без подписи (HUB_LEGACY=on)", "awg-ctrl hub legacy auto")
case "off":
ok("Хаб: только агенты с подписью")
default:
ok("Хаб: подпись до апгрейда; кто из нод ещё без неё — awg-ctrl hub")
}
}
}
fmt.Print("\n " + dim("Фаервол хостера (панель провайдера) отсюда не виден — открыты ли там лишние порты, проверьте сами.") + "\n\n")
}
// panelMenu — пункт меню: адрес панели и смена секретного пути.
func (c *CLI) panelMenu() {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — это нода")
return
}
c.showPanel()
fmt.Println(" 1) Новый случайный путь 2) Свой путь 3) Без пути Enter — назад")
switch strings.TrimSpace(c.ask(" › ")) {
case "1":
c.setPanelPath("new")
case "2":
if p := strings.TrimSpace(c.ask(" Путь (8–64 символа A–Z a–z 0–9 _ -): ")); p != "" {
c.setPanelPath(p)
}
case "3":
if c.confirmYes(" Без пути сканер снова узнает панель по странице логина. Убрать путь?") {
c.setPanelPath("off")
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package cli
// Обновить, переустановить, удалить — из меню awg-ctrl и командами `awg-ctrl update|reinstall|
// uninstall`. Работу делает install.sh (ACTION=… — без его меню): CLI находит скрипт, кладёт во
// временный файл и передаёт ему управление (exec), чтобы диалог шёл в том же терминале.
//
// Скрипт — только из /tmp: install.sh перед заменой файлов убивает процессы, в командной строке
// которых есть путь установки (kill_related). Запущенный из /opt/awg-control/install.sh — или
// текстом через `bash -c` — он убил бы сам себя.
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
// Откуда брать свежий установщик; INSTALLER_URL в cli.env — своё зеркало.
const defaultInstallerURL = "https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh"
var installerActions = map[string]struct{ title, what string }{
"update": {"Обновить", "новая версия; настройки, пользователи и роль сохранятся"},
"reinstall": {"Переустановить", "с нуля; данные можно будет сохранить"},
"uninstall": {"Удалить", "полностью: сервисы, данные, правила фаервола; копию данных предложит сохранить"},
}
// fetchInstaller — текст install.sh: для обновления и переустановки — свежий из сети (иначе
// поставилась бы та же версия), для удаления — копия из установки (работает и без сети).
// Копия — запасной путь и для переустановки.
func (c *CLI) fetchInstaller(action string) ([]byte, string, error) {
local := filepath.Join(c.root, "install.sh")
readLocal := func() ([]byte, string, error) {
b, err := os.ReadFile(local)
return b, local, err
}
if action == "uninstall" {
if b, src, err := readLocal(); err == nil {
return b, src, nil
}
}
url := env("INSTALLER_URL", defaultInstallerURL)
cl := &http.Client{Timeout: 30 * time.Second}
resp, err := cl.Get(url)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 {
b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err == nil && strings.HasPrefix(string(b), "#!") {
return b, url, nil
}
}
err = fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
if action == "reinstall" {
if b, src, lerr := readLocal(); lerr == nil {
fmt.Printf(" %s\n", dim("Установщик не скачался ("+err.Error()+") — берём копию из установки, версия та же"))
return b, src, nil
}
}
return nil, "", err
}
// runInstaller — подтверждение и exec install.sh с ACTION. Возвращается только при отказе или
// ошибке: после exec процесс CLI заменён установщиком.
func (c *CLI) runInstaller(action string, confirm bool) {
a, ok := installerActions[action]
if !ok {
errorf(" Неизвестное действие: %s", action)
return
}
if os.Geteuid() != 0 {
errorf(" Нужен root: sudo awg-ctrl %s", action)
return
}
if confirm && !c.confirmYes(fmt.Sprintf(" %s — %s. Продолжить?", a.title, a.what)) {
fmt.Println(" Отменено")
return
}
script, src, err := c.fetchInstaller(action)
if err != nil {
errorf(" Не удалось получить установщик: %v", err)
errorf(" %s", dim("Запустите его вручную (README, «Установка») или задайте INSTALLER_URL в "+c.envFile))
return
}
f, err := os.CreateTemp("", "awg-install-*.sh")
if err != nil {
errorf(" %v", err)
return
}
f.Write(script)
f.Close()
fmt.Printf(" %s\n\n", dim("Установщик: "+src))
bash := "/bin/bash"
if p, err := exec.LookPath("bash"); err == nil {
bash = p
}
// Исходное окружение, без cli.env: иначе UI_PORT, SERVER_NAME и прочие сохранённые значения
// установщик принял бы за явно заданные параметры.
environ := append(withoutKey(c.origEnv, "ACTION"), "ACTION="+action)
if err := syscall.Exec(bash, []string{"bash", f.Name()}, environ); err != nil {
errorf(" Не удалось запустить установщик: %v", err)
}
}
func withoutKey(environ []string, key string) []string {
out := make([]string, 0, len(environ)+1)
for _, kv := range environ {
if !strings.HasPrefix(kv, key+"=") {
out = append(out, kv)
}
}
return out
}
+304
View File
@@ -0,0 +1,304 @@
package cli
// `migrate export|import|cancel` — перенос панели с сервера «панель + VPN» на отдельную панель
// без VPN; архив и раскладка — internal/migrate (их же зовёт вкладка «Перенос»):
//
// старый: migrate export → архив: ui.db, ключи подписки и хаба, порты
// новый: migrate import → раскладывает архив, заводит ноду #0 и печатает join-строку
// старый: join '<строка>' → становится нодой #0 и поднимает ретранслятор прежних портов
import (
"crypto/rand"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func envPort(k string) int {
n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
if err != nil || n <= 0 || n > 65535 {
return 0
}
return n
}
func (c *CLI) paths() migrate.Paths {
return migrate.PathsFrom(func(env string) string { return c.svcEnv("ui", env) })
}
func (c *CLI) migrateCmd(sub, arg string, hasArg bool) {
switch sub {
case "export":
c.migrateExport(arg, hasArg)
case "import":
if !hasArg {
errorf(" Usage: awg-ctrl migrate import <архив>")
os.Exit(1)
}
c.migrateImport(arg)
case "cancel":
c.migrateCancel()
default:
errorf(" Usage: awg-ctrl migrate <export [файл]|import <архив>|cancel>")
os.Exit(1)
}
}
// ── export (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateExport(out string, hasOut bool) {
if c.isNode {
errorf(" Это нода: её панель — на core, переносить отсюда нечего")
os.Exit(1)
}
if !contains(c.svcList(), "awgctrl") {
errorf(" У этой панели нет своего VPN (LOCAL_NODE=off) — перенос рассчитан на сервер «панель + VPN»")
os.Exit(1)
}
dbFile := c.paths()["ui.db"]
if !exists(dbFile) {
errorf(" Нет базы панели: %s", dbFile)
os.Exit(1)
}
if !hasOut {
out = fmt.Sprintf("forgetting-migrate-%s.tar.gz", time.Now().Format("20060102-150405"))
}
fmt.Println()
fmt.Println(" Перенос панели на отдельный сервер. Сейчас:")
fmt.Println(" · панель и подписка sen:// здесь остановятся и больше не запустятся сами;")
fmt.Println(" · VPN продолжит работать: пиры и awg-ctrl не трогаются;")
fmt.Println(" · архив с базой панели и ключами подписки — " + bold(out))
if !c.confirmYes(" Продолжить?") {
return
}
c.stop("ui")
// Флаг — до снимка: перезапуск между ними поднял бы панель с базой, которая уже уехала.
content := updateEnvVar(c.readEnvFile(), "PANEL_MOVED", "on")
if err := c.writeEnvFile(content); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
if err := c.writeExport(out, dbFile); err != nil {
errorf(" Архив не создан: %v", err)
c.writeEnvFile(removeEnvVar(c.readEnvFile(), "PANEL_MOVED"))
os.Remove(out)
c.start("ui")
os.Exit(1)
}
fmt.Printf("\n %s Архив: %s %s\n", green("✓"), out, dim("(в нём ключ подписи — храните как пароль, после переноса удалите)"))
fmt.Println("\n Дальше:")
fmt.Println(" 1. Новая панель: install.sh, роль «только панель»")
fmt.Println(" 2. Скопируйте архив туда: " + dim("scp "+out+" root@<новая-панель>:"))
fmt.Println(" 3. На новой панели: " + bold("awg-ctrl migrate import "+filepath.Base(out)))
fmt.Println(" 4. Здесь — строку, которую она напечатает: " + bold("awg-ctrl join '<строка>'"))
fmt.Println(" " + dim("Передумали до шага 4: awg-ctrl migrate cancel"))
}
// svcList — сервисы роли без поправки на PANEL_MOVED и ретранслятор.
func (c *CLI) svcList() []string {
return without(without(c.all, "relay"), "ui")
}
func (c *CLI) writeExport(out, dbFile string) error {
db, err := migrate.OpenDB(dbFile)
if err != nil {
return err
}
defer db.Close()
name, ip := "VPN", os.Getenv("SERVER_IP")
if h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health"); h != nil {
name = strOr(get(h, "server"), name)
if ip == "" {
ip = strOr(get(h, "ip"), "")
}
}
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
err = migrate.Write(f, db, c.paths(), exportMeta(c.version, name, ip, envPort("UI_PORT"), envPort("SUB_PORT"),
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT"), env("UI_PREFIX", "")))
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int, uiPrefix string) jsjson.Object {
return jsjson.Object{
{Key: "version", Val: version}, {Key: "created_at", Val: time.Now().Unix()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: uiPort}, {Key: "sub_port", Val: subPort}, {Key: "sub_tls", Val: subTLS}, {Key: "node_port", Val: nodePort},
{Key: "ui_prefix", Val: uiPrefix},
}
}
// ── import (новая панель) ──────────────────────────────────────────────────
func (c *CLI) migrateImport(file string) {
if c.isNode {
errorf(" Это нода — импортировать панель нужно на core")
os.Exit(1)
}
if contains(c.svcList(), "awgctrl") {
errorf(" У этой панели свой VPN (LOCAL_NODE=on). Импорт — на панель без VPN: install.sh, роль «только панель»")
os.Exit(1)
}
f, err := os.Open(file)
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
b, err := migrate.Read(f)
f.Close()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer b.Close()
paths := c.paths()
if n, err := migrate.PanelData(paths["ui.db"]); err != nil || n > 0 {
errorf(" На этой панели уже есть данные (%s) — импорт их затёр бы. Нужна чистая панель.", paths["ui.db"])
os.Exit(1)
}
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
for host == "" || !reJoinHost.MatchString(host) {
host = strings.TrimSpace(c.ask(" Публичный адрес этой панели (IP или домен): "))
if host == "" {
return
}
}
plan, err := migrate.NewPlan(b, envPort, envPort("UI_PORT"), host)
if err != nil {
errorf(" %v (%s)", err, c.envFile)
os.Exit(1)
}
fmt.Println()
from := b.Str("server_ip")
if v := b.Str("version"); v != "" {
from += ", версия " + v
}
fmt.Printf(" Импорт панели «%s» (%s)\n", b.Str("server_name"), from)
fmt.Printf(" · подписка :%d, хаб нод :%d, адрес панели %s\n", plan.SubPort, plan.NodePort, host)
fmt.Println(" · прежние файлы панели уйдут в *.bak-<время>")
if !b.Has["sub_sign.key"] {
fmt.Println(" · " + dim("ключа подписи в архиве нет — на старом сервере подписка не была настроена"))
}
if !c.confirmYes(" Продолжить?") {
return
}
c.stop("ui")
secret := make([]byte, 16)
rand.Read(secret)
if err := migrate.Install(b, paths); err != nil {
errorf(" %v", err)
os.Exit(1)
}
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
errorf(" ui.db: %v", err)
os.Exit(1)
}
content := c.readEnvFile()
for _, kv := range plan.Env {
content = updateEnvVar(content, kv[0], kv[1])
}
if err := c.writeEnvFile(content); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
// Подписка и хаб переезжают на порты старого сервера — их надо открыть, как install.sh.
opened, failed := migrate.OpenFirewall(plan.SubPort, plan.NodePort)
if len(opened) > 0 {
fmt.Printf(" %s UFW: открыты %s\n", green("✓"), strings.Join(opened, " "))
}
if len(failed) > 0 {
errorf(" UFW: не удалось открыть %s — откройте вручную: ufw allow <порт>/tcp", strings.Join(failed, " "))
}
c.restartFresh()
// Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте.
var pin string
for range 50 {
if pin, err = migrate.CertPin(paths["node_tls.crt"]); err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
errorf(" Нет сертификата хаба %s — проверьте лог awg-ui и выдайте строку в панели (сервер #0)", paths["node_tls.crt"])
os.Exit(1)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
fmt.Printf("\n %s Панель перенесена. На старом сервере выполните (строка действует час):\n\n", green("✓"))
fmt.Println(" awg-ctrl join '" + join + "'")
fmt.Println()
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
if !migrate.ValidUIPrefix(b.Str("ui_prefix")) && env("UI_PREFIX", "") != "" {
fmt.Println(" " + dim("У старой панели не было секретного пути: её закладкам и внешнему API нужен путь этой — /"+env("UI_PREFIX", "")+"/"))
}
}
// ── cancel (старый сервер) ─────────────────────────────────────────────────
func (c *CLI) migrateCancel() {
if !c.panelMoved() {
fmt.Println(" Переноса не было: панель на месте")
return
}
if c.isNode {
errorf(" Сервер уже нода новой панели — база там, и вернуть её сюда так нельзя.")
errorf(" Если всё же нужно: awg-ctrl unjoin, затем верните ui.db с новой панели вручную.")
os.Exit(1)
}
content := c.readEnvFile()
for _, k := range []string{"PANEL_MOVED", "RELAY"} {
content = removeEnvVar(content, k)
}
c.writeEnvFile(content)
fmt.Printf(" %s Перенос отменён, панель запускается здесь снова\n", green("✓"))
fmt.Println(" " + dim("Если архив уже импортирован на новую панель — не запускайте там ui, пока она не очищена"))
c.restartFresh()
}
// ── rehome (нода) ──────────────────────────────────────────────────────────
// rehome — панель переехала на новый адрес: тот же pin и id, другой CORE_HOST[:CORE_PORT].
func (c *CLI) rehome(addr string, hasAddr bool) {
if !c.isNode {
errorf(" Это не нода — менять адрес core нечему")
os.Exit(1)
}
host, port := addr, ""
if h, p, err := net.SplitHostPort(addr); err == nil {
host, port = h, p
}
if p, err := strconv.Atoi(port); !hasAddr || !reJoinHost.MatchString(host) || port != "" && (err != nil || p <= 0 || p > 65535) {
errorf(" Usage: awg-ctrl rehome <хост[:порт]> (адрес новой панели; pin и id ноды остаются)")
os.Exit(1)
}
content := updateEnvVar(c.readEnvFile(), "CORE_HOST", host)
if port != "" {
content = updateEnvVar(content, "CORE_PORT", port)
} else {
port = os.Getenv("CORE_PORT")
}
// Адрес, указанный руками, главнее того, что сообщила панель.
c.writeEnvFile(removeEnvVar(content, "CORE_ENDPOINT"))
fmt.Printf(" %s Core: %s:%s\n", green("✓"), host, port)
c.restartFresh()
}
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
func decodeJoin(t *testing.T, s string) jsjson.Object {
t.Helper()
v, err := jsjson.Decode(fromB64url(strings.TrimPrefix(s, "awgjoin://")))
o, ok := v.(jsjson.Object)
if err != nil || !ok {
t.Fatalf("join-строка не разбирается: %v", err)
}
return o
}
// export берёт пути и порты из окружения сервисов, как их видит awg-ui.
func TestWriteExportFromEnv(t *testing.T) {
dir := t.TempDir()
sign := filepath.Join(dir, "sign")
os.WriteFile(sign, []byte("sign"), 0o600)
dbFile := filepath.Join(dir, "ui.db")
db, err := migrate.OpenDB(dbFile)
if err != nil {
t.Fatal(err)
}
db.Exec("CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
db.Exec("INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')")
db.Close()
for k, v := range map[string]string{
"AWG_ROOT": dir, "UI_DB_FILE": dbFile, "SUB_SIGN_KEY_FILE": sign, "AWGCTRL_PORT": "1", "SERVER_IP": "198.51.100.1",
"SUB_PORT": "40123", "SUB_TLS": "on", "NODE_PORT": "40500", "UI_PORT": "33000",
"SUB_TLS_KEY_FILE": filepath.Join(dir, "нет"), "SUB_TLS_CERT_FILE": filepath.Join(dir, "нет"),
"NODE_TLS_KEY_FILE": filepath.Join(dir, "нет"), "NODE_TLS_CERT_FILE": filepath.Join(dir, "нет"),
} {
t.Setenv(k, v)
}
out := filepath.Join(dir, "bundle.tar.gz")
if err := New().writeExport(out, dbFile); err != nil {
t.Fatal(err)
}
if st, _ := os.Stat(out); st.Mode().Perm() != 0o600 {
t.Errorf("права архива %v — в нём ключ подписи", st.Mode().Perm())
}
f, _ := os.Open(out)
b, err := migrate.Read(f)
f.Close()
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Str("server_ip") != "198.51.100.1" || b.Str("server_name") != "VPN" ||
b.Num("sub_port") != 40123 || b.Num("node_port") != 40500 || b.Num("ui_port") != 33000 || b.Meta.Get2("sub_tls") != true ||
!b.Has["sub_sign.key"] || b.Has["sub_tls.key"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
}
func TestRelayRoutes(t *testing.T) {
j := decodeJoin(t, migrate.JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123))
t.Setenv("SUB_PORT", "40123")
t.Setenv("NODE_PORT", "40400")
t.Setenv("UI_PORT", "32000")
if got, want := relayRoutes("203.0.113.7", 40500, j), "40123=203.0.113.7:40123,40400=203.0.113.7:40500,32000=203.0.113.7:33100"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
// Строка, выданная панелью заново (без ui/sub): подписка — на тот же порт, панель не пересылаем.
t.Setenv("NODE_PORT", "")
plain := decodeJoin(t, migrate.JoinString("2001:db8::7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 0, 0))
if got, want := relayRoutes("2001:db8::7", 40500, plain), "40123=[2001:db8::7]:40123"; got != want {
t.Errorf("relay = %s, ожидали %s", got, want)
}
}
+308
View File
@@ -0,0 +1,308 @@
package cli
// `awg-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор» (порт
// cli/src/monitor.ts). Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не
// ставит, она только просит (через awg-ctrl) или показывает эту команду. Всё слушает
// 127.0.0.1: наружу Prometheus не торчит, а панель читает его по HTTP на той же машине.
// На ноде без своей панели и скрейп, и чтение идут через агента: метрики панели он берёт у
// core по WSS (AGENT_METRICS_PORT), а запросы вкладки «Монитор» пропускает к Prometheus.
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
const (
awgDir = "/etc/amnezia/amneziawg"
promDir = "/etc/prometheus"
promConf = promDir + "/prometheus.yml"
promToken = promDir + "/forgetting.token"
monitorHashFile = awgDir + "/monitor_token.sha256" // хэш локального токена: его читает awg-ui
promDefaults = "/etc/default/prometheus"
promMarker = "# managed by forgetting"
promListen = "127.0.0.1:9090"
promRetention = "31d" // с запасом на вид «30 дней» во вкладке «Монитор»
)
func mlog(s string) { fmt.Println(" " + s) }
func mfail(s string) {
fmt.Fprintf(os.Stderr, "\n ✗ %s\n\n", s)
os.Exit(1)
}
type shResult struct {
ok bool
output string
}
// sh — команда без stdin; quiet — вывод забираем себе, иначе он идёт в терминал.
func sh(quiet bool, name string, args ...string) shResult {
cmd := exec.Command(name, args...)
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
if quiet {
out, err := cmd.CombinedOutput()
return shResult{err == nil, string(out)}
}
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return shResult{cmd.Run() == nil, ""}
}
var (
reOSID = regexp.MustCompile(`(?m)^ID=(.*)$`)
reOSIDLike = regexp.MustCompile(`(?m)^ID_LIKE=(.*)$`)
reDebian = regexp.MustCompile(`(?i)debian|ubuntu`)
rePromVer = regexp.MustCompile(`version (\d+)\.(\d+)`)
reArgsLine = regexp.MustCompile(`(?m)^ARGS=.*$`)
)
func osFamily() string {
raw, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
val := func(re *regexp.Regexp) string {
if m := re.FindStringSubmatch(string(raw)); m != nil {
return strings.ReplaceAll(m[1], `"`, "")
}
return ""
}
return val(reOSID) + " " + val(reOSIDLike)
}
func promReady() bool {
cl := &http.Client{Timeout: 1500 * time.Millisecond, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Get("http://" + promListen + "/-/ready")
if err != nil {
return false
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp.StatusCode == 200
}
func promInstalled() bool { return sh(true, "dpkg", "-s", "prometheus").ok }
func promActive() bool { return sh(true, "systemctl", "is-active", "--quiet", "prometheus").ok }
func promManaged() bool {
raw, err := os.ReadFile(promConf)
return err == nil && strings.Contains(string(raw), promMarker)
}
// authBlock — новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран.
func authBlock() string {
r := sh(true, "prometheus", "--version")
maj, min := 2, 0
if m := rePromVer.FindStringSubmatch(r.output); m != nil {
maj, _ = strconv.Atoi(m[1])
min, _ = strconv.Atoi(m[2])
}
if maj > 2 || maj == 2 && min >= 26 {
return " authorization:\n credentials_file: " + promToken + "\n"
}
return " bearer_token_file: " + promToken + "\n"
}
// backupStamp — new Date().toISOString() с «:» и «.» → «-».
func backupStamp() string {
return strings.NewReplacer(":", "-", ".", "-").Replace(time.Now().UTC().Format("2006-01-02T15:04:05.000Z"))
}
func copyFile(src, dst string) {
if raw, err := os.ReadFile(src); err == nil {
os.WriteFile(dst, raw, 0o644)
}
}
// writePromConfig — target: порт панели на этой машине или агента ноды.
func writePromConfig(target string) {
if exists(promConf) {
copyFile(promConf, promConf+".bak-"+backupStamp())
}
os.WriteFile(promConf, []byte(promMarker+`
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: forgetting
scheme: http
metrics_path: /metrics
`+authBlock()+` static_configs:
- targets: ['127.0.0.1:`+target+`']
`), 0o644)
}
// writePromToken — токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш.
func writePromToken() {
b := make([]byte, 24)
rand.Read(b)
token := "awgm_" + b64url.EncodeToString(b)
os.WriteFile(promToken, []byte(token), 0o640)
sh(true, "chown", "root:prometheus", promToken)
sum := sha256.Sum256([]byte(token))
os.WriteFile(monitorHashFile, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
}
func setPromArgs() {
line := `ARGS="--web.listen-address=` + promListen + ` --storage.tsdb.retention.time=` + promRetention + `"`
raw, _ := os.ReadFile(promDefaults)
text := string(raw)
if text != "" {
copyFile(promDefaults, promDefaults+".bak-"+backupStamp())
}
if loc := reArgsLine.FindStringIndex(text); loc != nil {
text = text[:loc[0]] + line + text[loc[1]:]
} else {
text = strings.TrimSuffix(text, "\n") + "\n" + line + "\n"
}
os.WriteFile(promDefaults, []byte(text), 0o644)
}
func (c *CLI) monitorInstall() {
if os.Getuid() != 0 {
mfail("Нужен root: sudo awg-ctrl monitor install")
}
if !reDebian.MatchString(osFamily()) {
mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).")
}
// Нода без своей панели (в том числе сервер, с которого панель переехала): метрики панели
// Prometheus получает от агента, а тот — от core.
viaAgent := c.isNode && !contains(c.all, "ui")
target, newPort := os.Getenv("UI_PORT"), false
if viaAgent {
if target = os.Getenv("AGENT_METRICS_PORT"); target == "" {
if target = freeLocalPort(); target == "" {
mfail("Не нашлось свободного порта для агента")
}
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "AGENT_METRICS_PORT", target)); err != nil {
mfail("Не удалось записать " + c.envFile + ": " + err.Error())
}
c.setSvcEnv("agent", "AGENT_METRICS_PORT", target)
newPort = true
}
} else if target == "" {
mfail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.")
}
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
if promInstalled() && exists(promConf) && !promManaged() {
mfail("Prometheus уже установлен и настроен не нами (" + promConf + "). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.")
}
mlog("1/4 установка пакета prometheus")
if !promInstalled() {
if !sh(false, "apt-get", "update", "-qq").ok {
mfail("apt-get update завершился ошибкой")
}
if !sh(false, "apt-get", "install", "-y", "--no-install-recommends", "prometheus").ok {
mfail("apt-get install prometheus завершился ошибкой")
}
} else {
mlog("уже установлен")
}
mlog("2/4 токен и конфиг")
writePromToken()
writePromConfig(target)
if newPort {
mlog("агент отдаёт метрики панели на 127.0.0.1:" + target + " — перезапуск агента")
c.stop("agent")
c.start("agent")
}
mlog("3/4 слушаем только " + promListen + ", хранение " + promRetention)
setPromArgs()
mlog("4/4 запуск")
sh(true, "systemctl", "enable", "prometheus")
if !sh(false, "systemctl", "restart", "prometheus").ok {
mfail("prometheus не запустился: journalctl -u prometheus")
}
for range 30 {
if promReady() {
fmt.Printf("\n ✓ Prometheus работает на %s, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n\n", promListen)
if viaAgent {
fmt.Print(" Панель читает его через агента этой ноды.\n\n")
}
return
}
time.Sleep(time.Second)
}
mfail("Prometheus не ответил за 30 с: journalctl -u prometheus")
}
// freeLocalPort — свободный порт из того же диапазона, что у install.sh (32768–55535).
func freeLocalPort() string {
for range 50 {
n, err := rand.Int(rand.Reader, big.NewInt(55535-32768+1))
if err != nil {
return ""
}
p := strconv.Itoa(32768 + int(n.Int64()))
if l, err := net.Listen("tcp", "127.0.0.1:"+p); err == nil {
l.Close()
return p
}
}
return ""
}
func yesNo(b bool, yes, no string) string {
if b {
return yes
}
return no
}
func monitorStatus() {
ok := promReady()
fmt.Println(" пакет: " + yesNo(promInstalled(), "установлен", "нет"))
fmt.Println(" сервис: " + yesNo(promActive(), "работает", "остановлен"))
fmt.Println(" наш конфиг: " + yesNo(promManaged(), "да", "нет"))
fmt.Println(" готов: " + yesNo(ok, "да, "+promListen, "нет"))
}
func monitorRemove() {
if os.Getuid() != 0 {
mfail("Нужен root: sudo awg-ctrl monitor remove")
}
// Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили.
if promInstalled() && exists(promConf) && !promManaged() {
mfail("Prometheus настроен не нами (" + promConf + "), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus")
}
mlog("1/3 остановка сервиса")
sh(true, "systemctl", "disable", "--now", "prometheus")
mlog("2/3 токен")
os.Remove(promToken)
os.Remove(monitorHashFile)
mlog("3/3 удаление пакета и накопленных данных")
if promInstalled() && !sh(false, "apt-get", "purge", "-y", "prometheus").ok {
mfail("apt-get purge prometheus завершился ошибкой")
}
fmt.Print("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n\n")
}
func (c *CLI) monitorCmd(sub string) {
switch sub {
case "install":
c.monitorInstall()
case "status":
monitorStatus()
case "remove":
monitorRemove()
default:
fmt.Println("Usage: awg-ctrl monitor <install|status|remove>")
os.Exit(1)
}
}
+458
View File
@@ -0,0 +1,458 @@
package ctrl
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode"
)
// ───────── запуск команд ─────────
type cmdResult struct {
Stdout, Stderr string
Code int // -1: не запустилась
}
func (r cmdResult) ok() bool { return r.Code == 0 }
func runCmdIn(stdin string, name string, args ...string) cmdResult {
cmd := exec.Command(name, args...)
var so, se bytes.Buffer
cmd.Stdout, cmd.Stderr = &so, &se
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
err := cmd.Run()
res := cmdResult{Stdout: so.String(), Stderr: se.String()}
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
res.Code = ee.ExitCode()
} else {
res.Code = -1
}
}
return res
}
func runCmd(name string, args ...string) cmdResult { return runCmdIn("", name, args...) }
// runShell — аналог execSync(cmd).trim(): через sh, ошибка при ненулевом коде.
func runShell(cmd string) (string, error) {
r := runCmdIn("", "/bin/sh", "-c", cmd)
if !r.ok() {
return "", fmt.Errorf("Command failed: %s\n%s", cmd, r.Stderr)
}
return strings.TrimSpace(r.Stdout), nil
}
func runCtx(timeout time.Duration, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var so, se bytes.Buffer
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout, cmd.Stderr = &so, &se
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(se.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), msg)
}
return so.String(), nil
}
// jsNumber — Number(s): пустая строка = 0, нечисло = NaN.
func jsNumber(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return math.NaN()
}
return f
}
// jsonStamp — new Date().toISOString().replace(/[:.]/g, "-").
func jsonStamp(t time.Time) string {
t = t.UTC()
return t.Format("2006-01-02T15-04-05") + fmt.Sprintf("-%03dZ", t.Nanosecond()/1e6)
}
// ───────── ключи ─────────
func (s *Server) generateKeys() (Keys, error) {
priv, err := runShell("umask 077 && awg genkey")
if err != nil {
return Keys{}, err
}
r := runCmdIn(priv, "awg", "pubkey")
if !r.ok() {
return Keys{}, errors.New("awg pubkey завершился с ошибкой")
}
psk, err := runShell("awg genpsk")
if err != nil {
return Keys{}, err
}
return Keys{Private: priv, Public: strings.TrimSpace(r.Stdout), PSK: psk}, nil
}
func (s *Server) genPSK() (string, error) { return runShell("awg genpsk") }
func (s *Server) getServerPublicKey() (string, error) {
b, err := os.ReadFile(filepath.Join(s.confDir, "server_public.key"))
if err != nil {
return "", errors.New("server_public.key не найден")
}
return strings.TrimSpace(string(b)), nil
}
// ───────── интерфейс ─────────
type peerStat struct {
Online bool
LastHandshake float64
Rx, Tx float64
}
// getPeersData: `awg show awg1 dump`, колонки [0] pub, [4] handshake, [5] rx, [6] tx.
func (s *Server) getPeersData() map[string]peerStat {
out, err := runShell("awg show " + s.iface + " dump")
if err != nil {
s.log.Warn("getPeersData failed", "error", err)
return map[string]peerStat{}
}
res := map[string]peerStat{}
now := float64(time.Now().Unix())
lines := strings.Split(out, "\n")
for i := 1; i < len(lines); i++ {
parts := strings.Split(lines[i], "\t")
pub := parts[0]
if pub == "" {
continue
}
get := func(n int) float64 {
if n < len(parts) {
return jsNumber(parts[n])
}
return math.NaN() // Number(undefined)
}
hs := get(4)
res[pub] = peerStat{
Online: hs > 0 && (now-hs) < 180,
LastHandshake: hs, Rx: get(5), Tx: get(6),
}
}
return res
}
type ifStatus struct {
Up bool `json:"up"`
Peers int `json:"peers"`
PublicKey *string `json:"publicKey"`
}
var (
rePeerLine = regexp.MustCompile(`(?m)^peer:`)
rePubKey = regexp.MustCompile(`public key:\s*(.+)`)
)
func (s *Server) interfaceStatus() ifStatus {
r := runCmd("awg", "show", s.iface)
if !r.ok() {
return ifStatus{}
}
st := ifStatus{Up: true, Peers: len(rePeerLine.FindAllString(r.Stdout, -1))}
if m := rePubKey.FindStringSubmatch(r.Stdout); m != nil {
v := strings.TrimSpace(m[1])
st.PublicKey = &v
}
return st
}
func (s *Server) ensureInterfaceUp() error {
if !s.interfaceStatus().Up {
return fmt.Errorf("Interface %s is not up. Run: awg-quick up %s", s.iface, s.iface)
}
return nil
}
// readVersions: версия загруженного модуля (важнее версии на диске: интерфейс обслуживает
// именно загруженный) и awg-tools. Вызывается на старте и при restartAwg — только тогда
// модуль может смениться; из /health не вызывается (он без авторизации).
func readVersions() (module, tools string) {
if b, err := os.ReadFile("/sys/module/amneziawg/version"); err == nil {
module = strings.TrimSpace(string(b))
} else if v, err := runShell("modinfo -F version amneziawg"); err == nil {
module = v
}
if v, err := runShell("awg --version"); err == nil {
for _, t := range strings.Fields(v) {
if regexp.MustCompile(`^v?\d`).MatchString(t) {
tools = t
break
}
}
}
return
}
// ───────── awg1.conf ─────────
func (s *Server) confPath() string { return filepath.Join(s.confDir, s.iface+".conf") }
func trimEndJS(s string) string { return strings.TrimRightFunc(s, unicode.IsSpace) }
// rebuildConf пересобирает [Peer]-секции из БД; [Interface] сохраняется как есть.
// Пиры устройств идут после users; без записей в peers вывод байт-в-байт прежний.
func (s *Server) rebuildConf() error {
users, err := s.db.Query("SELECT name, ip, pub_key, psk_key FROM users")
if err != nil {
return err
}
type pr struct{ label, ip, pub, psk string }
var all []pr
for users.Next() {
var n, ip, pub, psk string
if err := users.Scan(&n, &ip, &pub, &psk); err != nil {
users.Close()
return err
}
all = append(all, pr{n, ip, pub, psk})
}
users.Close()
devices, err := s.peersAll()
if err != nil {
return err
}
if _, err := os.Stat(s.confPath()); err != nil {
return nil
}
raw, err := os.ReadFile(s.confPath())
if err != nil {
return err
}
for _, d := range devices {
label := d.Owner
if label == "" {
label = "device"
}
all = append(all, pr{label, d.IP, d.PubKey, d.PskKey})
}
iface := trimEndJS(pPeerSplit.Split(string(raw), 2)[0])
ka := s.cfg().Keepalive
blocks := make([]string, 0, len(all))
for _, u := range all {
blocks = append(blocks, "\n# "+u.label+"\n[Peer]\nPublicKey = "+u.pub+"\nPresharedKey = "+u.psk+
"\nAllowedIPs = "+u.ip+"/32\nPersistentKeepalive = "+ka)
}
if err := os.WriteFile(s.confPath(), []byte(iface+"\n"+strings.Join(blocks, "\n")+"\n"), 0o600); err != nil {
return err
}
s.log.Info("conf rebuilt", "peers", len(all))
return nil
}
var reAwgQuickOnly = regexp.MustCompile(`(?i)^\s*(Address|DNS|MTU|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)\s*=`)
var rePrivateKeyLine = regexp.MustCompile(`^\s*PrivateKey\s*=`)
var reInterfaceLine = regexp.MustCompile(`^\s*\[Interface\]`)
// buildSyncConf готовит «stripped»-конфиг для `awg syncconf`: без awg-quick-ключей, но
// с [Interface] и PrivateKey.
//
// 🛑 КРИТИЧНО: [Interface] с PrivateKey ОБЯЗАН попасть в этот конфиг. Когда syncconf
// получал только [Peer]-блоки, AmneziaWG обнулял приватный ключ интерфейса и параметры
// обфускации — сервер поднимался с public-key=(none), и ВСЕ клиенты отваливались.
// PrivateKey берём из server_private.key — та же идентичность, что в server_public.key
// (его зашивают в vpn:// ключи) и в PostUp.
func (s *Server) buildSyncConf() (string, error) {
privRaw, err := os.ReadFile(s.serverPrivKeyFile)
if err != nil {
return "", err
}
priv := strings.TrimSpace(string(privRaw))
raw, err := os.ReadFile(s.confPath())
if err != nil {
return "", err
}
var out []string
replaced := false
for _, line := range strings.Split(string(raw), "\n") {
if reAwgQuickOnly.MatchString(line) {
continue
}
if rePrivateKeyLine.MatchString(line) {
out = append(out, "PrivateKey = "+priv)
replaced = true
continue
}
out = append(out, line)
}
if !replaced {
for i, l := range out {
if reInterfaceLine.MatchString(l) {
out = append(out[:i+1], append([]string{"PrivateKey = " + priv}, out[i+1:]...)...)
break
}
}
}
return strings.Join(out, "\n"), nil
}
func (s *Server) syncPeers() error {
if err := s.rebuildConf(); err != nil {
return err
}
cfg, err := s.buildSyncConf()
if err != nil {
return err
}
f, err := os.CreateTemp("", "awg_sync_*.conf") // 0600, O_EXCL, случайное имя
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.WriteString(cfg); err != nil {
f.Close()
return err
}
f.Close()
if r := runCmd("awg", "syncconf", s.iface, f.Name()); !r.ok() {
s.log.Warn("syncPeers syncconf failed", "stderr", r.Stderr)
}
var n int
s.db.QueryRow("SELECT COUNT(*) AS n FROM users").Scan(&n)
s.log.Info("peers synced", "count", n)
return nil
}
// reloadAwgParams перечитывает параметры после любой правки конфига: иначе ключи
// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1.
func (s *Server) reloadAwgParams() string {
p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
p.I1 = s.getCfg(cfgI1, p.I1)
mtu := ReadMTU(s.confPath())
s.cfgMu.Lock()
s.c.Params = p
s.c.MTU = mtu
s.c.Gen = p.Gen()
s.c.Keepalive = keepaliveByGen[s.c.Gen]
g := s.c.Gen
s.cfgMu.Unlock()
s.log.Info("awg params loaded from conf", "Jc", p.Jc, "H1", p.H1, "gen", g)
return g
}
func (s *Server) restartAwg() error {
s.log.Info("AWG restart: down")
if r := runCmd("awg-quick", "down", s.iface); !r.ok() {
s.log.Warn("awg-quick down failed", "stderr", r.Stderr)
}
s.log.Info("AWG restart: up")
if r := runCmd("awg-quick", "up", s.iface); !r.ok() {
return fmt.Errorf("awg-quick up failed: %s", r.Stderr)
}
// Конфиг мог измениться, пока интерфейс лежал (переход на 3.1, ручная правка) —
// перечитываем ДО syncPeers: keepalive из CONFIG уходит в [Peer]-блоки.
gen := s.reloadAwgParams()
if err := s.syncPeers(); err != nil {
return err
}
go s.shaping.Apply() // tc-правила жили на интерфейсе, который мы только что пересоздали
m, t := readVersions()
s.cfgMu.Lock()
s.c.AwgModule, s.c.AwgTools = m, t
s.cfgMu.Unlock()
s.log.Info("AWG restart: done", "module", m, "tools", t, "gen", gen)
return nil
}
func (s *Server) startInterface() (ifStatus, error) {
st := s.interfaceStatus()
if st.Up {
s.log.Info("AWG already up", "peers", st.Peers)
return st, nil
}
if r := runCmd("awg-quick", "up", s.iface); !r.ok() {
return ifStatus{}, fmt.Errorf("awg-quick up failed: %s", r.Stderr)
}
if err := s.syncPeers(); err != nil {
return ifStatus{}, err
}
go s.shaping.Apply()
return s.interfaceStatus(), nil
}
// setPeer: PSK уходит во временный файл, а не в аргументы — иначе он виден в /proc/<pid>/cmdline.
func (s *Server) setPeer(pub, psk, ip string) cmdResult {
f, err := os.CreateTemp("", "awg_psk_*.tmp")
if err != nil {
return cmdResult{Stderr: err.Error(), Code: -1}
}
defer os.Remove(f.Name())
f.WriteString(psk)
f.Close()
return runCmd("awg", "set", s.iface, "peer", pub,
"preshared-key", f.Name(),
"allowed-ips", ip+"/32",
"persistent-keepalive", s.cfg().Keepalive)
}
// kernelPeers — ключи пиров, стоящих в ядре (nil — не удалось спросить).
func (s *Server) kernelPeers() map[string]bool {
r := runCmd("awg", "show", s.iface, "peers")
if !r.ok() {
return nil
}
set := map[string]bool{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l != "" {
set[l] = true
}
}
return set
}
// dropFromKernel снимает пира с интерфейса и убеждается, что его там нет. Порядок во
// всех вызывающих: сначала ядро, потом база — при отказе строка остаётся, и повторная
// попытка (надгробие панели, следующий sync) найдёт пира.
func (s *Server) dropFromKernel(pub string) bool {
r := runCmd("awg", "set", s.iface, "peer", pub, "remove")
if r.ok() {
return true
}
if left := s.kernelPeers(); left != nil && !left[pub] {
return true // ответ странный, но пира уже нет
}
s.log.Error("peer remove from kernel failed", "pub", pub, "stderr", r.Stderr)
return false
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
func b64Std(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
func stdB64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
// lenientB64URL повторяет Buffer.from(s, "base64url") в Node: терпит паддинг, «+», «/».
func lenientB64URL(s string) ([]byte, error) {
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
return base64.RawURLEncoding.DecodeString(s)
}
+287
View File
@@ -0,0 +1,287 @@
package ctrl
// Журнал VPN-подключений: кто, когда подключился и отключился, сколько длилась сессия и сколько
// прошло трафика, и когда каждый пир был на связи последний раз (для «кто давно не подключался»).
//
// У WireGuard нет событий подключения — только время последнего handshake и счётчики байт в
// `awg show dump`. Раз в 30 с сверяем их с открытыми сессиями:
// - пир стал онлайн (handshake моложе 3 мин) — «подключился» со временем этого handshake;
// - пир ушёл из онлайна или с интерфейса — «отключился» со временем последней активности
// (последнего тика, когда менялись счётчики): handshake идёт раз в 2 мин, активность точнее.
// Открытые сессии восстанавливаются из базы, поэтому перезапуск awg-ctrl не плодит дублей.
// Внешний IP клиента не пишем: это уже слежка, а не журнал.
import (
"strconv"
"strings"
"sync"
"time"
)
const (
connlogEvery = 30 * time.Second
connlogKeepS = 90 * 24 * 3600 // столько дней храним события
seenEveryS = 60 // last_seen пишем не чаще раза в минуту на пира
connlogLimit = 500
)
type connSession struct {
since int64
rx0, tx0 float64 // счётчики на начало сессии
lastRx, lastTx float64
active int64 // последний тик, когда менялись счётчики
seenSaved int64
}
type peerIdent struct{ name, owner, ip string }
type ConnLog struct {
s *Server
mu sync.Mutex
open map[string]*connSession
loaded bool
pruned int64
peersFn func() (map[string]peerStat, bool) // подменяется в тестах
}
func newConnLog(s *Server) *ConnLog {
l := &ConnLog{s: s, open: map[string]*connSession{}}
l.peersFn = func() (map[string]peerStat, bool) {
if !s.interfaceStatus().Up {
return nil, false // интерфейс лежит — это не «все отключились», а пауза
}
return s.getPeersData(), true
}
return l
}
func (l *ConnLog) Start() {
go func() {
for {
l.tick(time.Now().Unix())
time.Sleep(connlogEvery)
}
}()
}
// idents — имя пользователя или владелец пира sen:// и внутренний IP по публичному ключу. Строки
// читаем до конца до любых других запросов: соединение с SQLite одно (см. metrics_test.go).
func (l *ConnLog) idents() map[string]peerIdent {
out := map[string]peerIdent{}
if rows, err := l.s.db.Query("SELECT pub_key, name, ip FROM users"); err == nil {
for rows.Next() {
var pub, name, ip string
if rows.Scan(&pub, &name, &ip) == nil {
out[pub] = peerIdent{name: name, ip: ip}
}
}
rows.Close()
}
if rows, err := l.s.db.Query("SELECT pub_key, owner, ip FROM peers"); err == nil {
for rows.Next() {
var pub, owner, ip string
if rows.Scan(&pub, &owner, &ip) == nil {
out[pub] = peerIdent{owner: owner, ip: ip}
}
}
rows.Close()
}
return out
}
// load — открытые сессии (последнее событие пира — «подключился») после перезапуска.
func (l *ConnLog) load(now int64) {
type row struct {
pub string
at, rx, tx int64
seen int64
}
var open []row
rows, err := l.s.db.Query(`SELECT c.pub_key, c.at, COALESCE(c.rx, 0), COALESCE(c.tx, 0), COALESCE(s.at, c.at)
FROM conn_log c
JOIN (SELECT pub_key, MAX(id) AS id FROM conn_log GROUP BY pub_key) last ON last.id = c.id
LEFT JOIN peer_seen s ON s.pub_key = c.pub_key
WHERE c.event = 'connect'`)
if err != nil {
return
}
for rows.Next() {
var r row
if rows.Scan(&r.pub, &r.at, &r.rx, &r.tx, &r.seen) == nil {
open = append(open, r)
}
}
rows.Close()
for _, r := range open {
l.open[r.pub] = &connSession{since: r.at, rx0: float64(r.rx), tx0: float64(r.tx),
lastRx: float64(r.rx), lastTx: float64(r.tx), active: r.seen, seenSaved: r.seen}
}
l.loaded = true
}
func (l *ConnLog) tick(now int64) {
stats, ok := l.peersFn()
if !ok {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if !l.loaded {
l.load(now)
}
who := l.idents()
write := func(event, pub string, at int64, dur *int64, rx, tx float64) {
id := who[pub]
var d any
if dur != nil {
d = *dur
}
l.s.db.Exec("INSERT INTO conn_log (at, event, pub_key, name, owner, ip, duration, rx, tx) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
at, event, pub, id.name, id.owner, id.ip, d, int64(rx), int64(tx))
}
seen := func(pub string, at int64) {
l.s.db.Exec("INSERT INTO peer_seen (pub_key, at) VALUES (?, ?) ON CONFLICT(pub_key) DO UPDATE SET at = excluded.at", pub, at)
}
closeSession := func(pub string, sess *connSession) {
at := max(sess.active, sess.since)
dur := at - sess.since
// Счётчики сбрасываются при перезапуске интерфейса: тогда считаем от нуля.
rx, tx := sess.lastRx-sess.rx0, sess.lastTx-sess.tx0
if rx < 0 {
rx = sess.lastRx
}
if tx < 0 {
tx = sess.lastTx
}
write("disconnect", pub, at, &dur, rx, tx)
seen(pub, at)
delete(l.open, pub)
}
for pub, st := range stats {
sess := l.open[pub]
switch {
case st.Online && sess == nil:
at := int64(st.LastHandshake)
if at <= 0 || at > now {
at = now
}
write("connect", pub, at, nil, st.Rx, st.Tx)
l.open[pub] = &connSession{since: at, rx0: st.Rx, tx0: st.Tx, lastRx: st.Rx, lastTx: st.Tx, active: now, seenSaved: now}
seen(pub, now)
case st.Online:
if st.Rx != sess.lastRx || st.Tx != sess.lastTx {
sess.lastRx, sess.lastTx, sess.active = st.Rx, st.Tx, now
}
if now-sess.seenSaved >= seenEveryS {
seen(pub, sess.active)
sess.seenSaved = now
}
case sess != nil:
if hs := int64(st.LastHandshake); hs > sess.active && hs <= now {
sess.active = hs
}
if st.Rx > sess.lastRx || st.Tx > sess.lastTx {
sess.lastRx, sess.lastTx = st.Rx, st.Tx
}
closeSession(pub, sess)
}
}
// Пира сняли с интерфейса (удалили пользователя, перевыпустили ключ) посреди сессии.
for pub, sess := range l.open {
if _, ok := stats[pub]; !ok {
closeSession(pub, sess)
}
}
if now-l.pruned >= 3600 {
l.s.db.Exec("DELETE FROM conn_log WHERE at < ?", now-connlogKeepS)
l.pruned = now
}
}
// list — GET /api/connlog: события (новые сверху) и «когда был на связи» по каждому пиру.
//
// ?limit=N (до 500), ?before=<id> — следующая страница, ?q=<строка> — по имени, владельцу, IP.
func (l *ConnLog) list(limit, before int64, q string) (ob, error) {
if limit <= 0 || limit > connlogLimit {
limit = connlogLimit
}
where, args := []string{"1 = 1"}, []any{}
if before > 0 {
where, args = append(where, "id < ?"), append(args, before)
}
if q = strings.TrimSpace(q); q != "" {
like := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(q) + "%"
where = append(where, `(name LIKE ? ESCAPE '\' OR owner LIKE ? ESCAPE '\' OR ip LIKE ? ESCAPE '\')`)
args = append(args, like, like, like)
}
args = append(args, limit)
rows, err := l.s.db.Query("SELECT id, at, event, pub_key, name, owner, ip, duration, rx, tx FROM conn_log WHERE "+
strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...)
if err != nil {
return nil, err
}
events := []any{}
for rows.Next() {
var id, at int64
var event, pub, name, owner, ip string
var dur, rx, tx *int64
if err := rows.Scan(&id, &at, &event, &pub, &name, &owner, &ip, &dur, &rx, &tx); err != nil {
rows.Close()
return nil, err
}
e := ob{{"id", id}, {"at", at}, {"event", event}, {"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip}}
if event == "disconnect" {
e = append(e, kv{"duration", deref(dur)}, kv{"rx", deref(rx)}, kv{"tx", deref(tx)})
}
events = append(events, e)
}
rows.Close()
// Все пиры сервера с последним временем на связи (null — с тех пор, как ведётся журнал, не было).
stats := map[string]peerStat{}
if st, ok := l.peersFn(); ok {
stats = st
}
peers := []any{}
rows, err = l.s.db.Query(`SELECT u.pub_key, u.name, '' AS owner, u.ip, s.at FROM users u LEFT JOIN peer_seen s ON s.pub_key = u.pub_key
UNION ALL
SELECT p.pub_key, '', p.owner, p.ip, s.at FROM peers p LEFT JOIN peer_seen s ON s.pub_key = p.pub_key`)
if err != nil {
return nil, err
}
for rows.Next() {
var pub, name, owner, ip string
var at *int64
if err := rows.Scan(&pub, &name, &owner, &ip, &at); err != nil {
rows.Close()
return nil, err
}
var last any
if at != nil {
last = *at
}
peers = append(peers, ob{{"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip},
{"online", stats[pub].Online}, {"last_seen", last}})
}
rows.Close()
var since any
var first int64
if l.s.db.QueryRow("SELECT MIN(at) FROM conn_log").Scan(&first) == nil && first > 0 {
since = first
}
return ob{{"events", events}, {"peers", peers}, {"since", since}, {"keep_days", connlogKeepS / 86400}}, nil
}
func deref(p *int64) any {
if p == nil {
return nil
}
return *p
}
func queryInt(c *rctx, k string) int64 {
n, _ := strconv.ParseInt(c.r.URL.Query().Get(k), 10, 64)
return n
}
+116
View File
@@ -0,0 +1,116 @@
package ctrl
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/logx"
)
func TestConnLog(t *testing.T) {
db, err := openDB(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES ('ivan', '10.9.0.2', 'PK_IVAN', 'vpn://x')")
db.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES ('PK_PHONE', '10.9.0.3', 'psk', 'p1/m1/d2', 1)")
s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())}
stats := map[string]peerStat{}
up := true
newLog := func() *ConnLog {
l := newConnLog(s)
l.peersFn = func() (map[string]peerStat, bool) {
cp := map[string]peerStat{}
for k, v := range stats {
cp[k] = v
}
return cp, up
}
return l
}
l := newLog()
peer := func(pub string, online bool, hs, rx, tx float64) {
stats[pub] = peerStat{Online: online, LastHandshake: hs, Rx: rx, Tx: tx}
}
events := func() string {
v, err := l.list(0, 0, "")
if err != nil {
t.Fatal(err)
}
var b []string
for _, e := range v.Get2("events").([]any) {
o := e.(jsjson.Object)
s := o.Get2("event").(string) + " " + o.Get2("name").(string) + o.Get2("owner").(string) + " @" + string(jsjson.MustMarshal(o.Get2("at")))
if o.Get2("event") == "disconnect" {
s += " dur=" + string(jsjson.MustMarshal(o.Get2("duration"))) + " rx=" + string(jsjson.MustMarshal(o.Get2("rx")))
}
b = append([]string{s}, b...) // старые сверху — читать проще
}
return strings.Join(b, "; ")
}
peer("PK_IVAN", false, 0, 0, 0)
peer("PK_PHONE", false, 0, 0, 0)
l.tick(1000)
if got := events(); got != "" {
t.Fatalf("без онлайна событий быть не должно: %s", got)
}
peer("PK_IVAN", true, 1010, 100, 50) // подключился, handshake в 1010
l.tick(1030)
peer("PK_IVAN", true, 1010, 900, 300) // качает
l.tick(1060)
peer("PK_IVAN", true, 1130, 900, 300) // keepalive-handshake без трафика
l.tick(1150)
up = false // интерфейс лёг — это не отключение
l.tick(1180)
up = true
peer("PK_IVAN", false, 1130, 900, 300)
l.tick(1330)
if got, want := events(), "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800"; got != want {
t.Fatalf("сессия:\n got %s\nwant %s", got, want)
}
// Перезапуск awg-ctrl посреди сессии: новый ConnLog поднимает открытую сессию из базы.
peer("PK_PHONE", true, 2000, 10, 10)
l.tick(2010)
l = newLog()
peer("PK_PHONE", true, 2100, 70, 10)
l.tick(2110)
// Пира сняли с интерфейса (удалили устройство) — сессия закрывается.
delete(stats, "PK_PHONE")
l.tick(2140)
want := "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800; connect p1/m1/d2 @2000; disconnect p1/m1/d2 @2110 dur=110 rx=60"
if got := events(); got != want {
t.Fatalf("после перезапуска:\n got %s\nwant %s", got, want)
}
// Счётчики сбросились (перезапуск интерфейса) — трафик сессии считается с нуля, не уходит в минус.
peer("PK_IVAN", true, 3000, 5000, 5000)
l.tick(3010)
peer("PK_IVAN", true, 3100, 40, 40)
l.tick(3110)
peer("PK_IVAN", false, 3100, 40, 40)
l.tick(3400)
if got := events(); !strings.HasSuffix(got, "connect ivan @3000; disconnect ivan @3110 dur=110 rx=40") {
t.Fatalf("сброс счётчиков: %s", got)
}
v, _ := l.list(0, 0, "")
seen := map[string]any{}
for _, p := range v.Get2("peers").([]any) {
o := p.(jsjson.Object)
seen[o.Get2("pub_key").(string)] = o.Get2("last_seen")
}
if seen["PK_IVAN"] != float64(3110) && seen["PK_IVAN"] != int64(3110) {
t.Errorf("last_seen ivan: %v", seen["PK_IVAN"])
}
if f, _ := l.list(0, 0, "ivan"); len(f.Get2("events").([]any)) != 4 {
t.Errorf("поиск по имени: %d событий", len(f.Get2("events").([]any)))
}
}
+253
View File
@@ -0,0 +1,253 @@
package ctrl
import (
"database/sql"
"fmt"
"net/url"
_ "modernc.org/sqlite"
)
// Схема users.db — та же, что у Node-версии: существующие базы открываются как есть.
// PRAGMA не задаём (журнал по умолчанию, без WAL); ждём блокировку 5 с, как better-sqlite3.
type userRow struct {
Name string `json:"name"`
IP string `json:"ip"`
PubKey string `json:"pub_key"`
VpnKey string `json:"vpn_key"`
PskKey string `json:"psk_key"`
KeyGen string `json:"key_gen"`
VpnKeyPrev string `json:"vpn_key_prev"`
}
type peerRow struct {
PubKey string
IP string
PskKey string
Owner string
CreatedAt int64
}
func openDB(path string) (*sql.DB, error) {
dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + "?_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// Один соединение: SQLite всё равно пишет по одному, а так нет «database is locked» между
// горутинами и Tx не конкурирует с запросами вне транзакции.
db.SetMaxOpenConns(1)
if err := initSchema(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func initSchema(db *sql.DB) error {
for _, q := range []string{
`CREATE TABLE IF NOT EXISTS users (
name TEXT PRIMARY KEY,
ip TEXT NOT NULL UNIQUE,
pub_key TEXT NOT NULL,
vpn_key TEXT NOT NULL,
psk_key TEXT NOT NULL DEFAULT ''
)`,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)",
} {
if _, err := db.Exec(q); err != nil {
return err
}
}
// ALTER TABLE идемпотентным не бывает — смотрим фактический список колонок.
cols := map[string]bool{}
rows, err := db.Query("PRAGMA table_info(users)")
if err != nil {
return err
}
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
cols[name] = true
}
rows.Close()
if !cols["key_gen"] {
if _, err := db.Exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'"); err != nil {
return err
}
}
if !cols["vpn_key_prev"] {
if _, err := db.Exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
}
for _, q := range []string{
`CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
// Пиры устройств sen://: приватного ключа здесь нет и быть не может.
`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
)`,
// Журнал VPN-подключений (connlog.go). name/owner/ip — на момент события: пользователя
// могут удалить, а запись о нём остаётся.
`CREATE TABLE IF NOT EXISTS conn_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
event TEXT NOT NULL,
pub_key TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
duration INTEGER,
rx INTEGER,
tx INTEGER
)`,
"CREATE INDEX IF NOT EXISTS idx_conn_log_at ON conn_log (at)",
"CREATE INDEX IF NOT EXISTS idx_conn_log_pub ON conn_log (pub_key, id)",
`CREATE TABLE IF NOT EXISTS peer_seen (
pub_key TEXT PRIMARY KEY,
at INTEGER NOT NULL
)`,
} {
if _, err := db.Exec(q); err != nil {
return err
}
}
return nil
}
type queryer interface {
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
Exec(query string, args ...any) (sql.Result, error)
}
func (s *Server) getCfg(key, fallback string) string {
var v string
if err := s.db.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&v); err != nil {
return fallback
}
return v
}
func (s *Server) setCfg(key, value string) {
if _, err := s.db.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, value); err != nil {
s.log.Error("setCfg failed", "key", key, "error", err)
}
}
const userCols = "name, ip, pub_key, vpn_key, psk_key, key_gen, vpn_key_prev"
func scanUsers(rows *sql.Rows) ([]userRow, error) {
defer rows.Close()
out := []userRow{}
for rows.Next() {
var u userRow
if err := rows.Scan(&u.Name, &u.IP, &u.PubKey, &u.VpnKey, &u.PskKey, &u.KeyGen, &u.VpnKeyPrev); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *Server) usersAll() ([]userRow, error) {
rows, err := s.db.Query("SELECT " + userCols + " FROM users")
if err != nil {
return nil, err
}
return scanUsers(rows)
}
func (s *Server) userGet(name string) (*userRow, error) {
rows, err := s.db.Query("SELECT "+userCols+" FROM users WHERE name = ?", name)
if err != nil {
return nil, err
}
us, err := scanUsers(rows)
if err != nil || len(us) == 0 {
return nil, err
}
return &us[0], nil
}
func scanPeers(rows *sql.Rows) ([]peerRow, error) {
defer rows.Close()
out := []peerRow{}
for rows.Next() {
var p peerRow
if err := rows.Scan(&p.PubKey, &p.IP, &p.PskKey, &p.Owner, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Server) peersAll() ([]peerRow, error) {
rows, err := s.db.Query("SELECT pub_key, ip, psk_key, owner, created_at FROM peers ORDER BY created_at, rowid")
if err != nil {
return nil, err
}
return scanPeers(rows)
}
func (s *Server) peerGet(pub string) (*peerRow, error) {
rows, err := s.db.Query("SELECT pub_key, ip, psk_key, owner, created_at FROM peers WHERE pub_key = ?", pub)
if err != nil {
return nil, err
}
ps, err := scanPeers(rows)
if err != nil || len(ps) == 0 {
return nil, err
}
return &ps[0], nil
}
func (s *Server) userPubTaken(q queryer, pub string) (bool, error) {
var n int
if err := q.QueryRow("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?", pub).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// nextIp: первый свободный 10.9.c.d (c = 0..255, d = 2..254) среди users и peers.
func (s *Server) nextIp(q queryer, subnet string) (string, error) {
rows, err := q.Query("SELECT ip FROM users UNION SELECT ip FROM peers")
if err != nil {
return "", err
}
used := map[string]bool{}
for rows.Next() {
var ip string
if err := rows.Scan(&ip); err != nil {
rows.Close()
return "", err
}
used[ip] = true
}
rows.Close()
for c := 0; c <= 255; c++ {
for d := 2; d <= 254; d++ {
ip := fmt.Sprintf("%s.%d.%d", subnet, c, d)
if !used[ip] {
return ip, nil
}
}
}
return "", fmt.Errorf("Подсеть заполнена")
}
+118
View File
@@ -0,0 +1,118 @@
package ctrl
import (
"math"
"regexp"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
var reOwner = regexp.MustCompile(`^[\w/.-]{0,64}$`)
// checkPubKeyAny — checkPubKey для значения из JSON (не строка → тот же 400).
func checkPubKeyAny(v any) (string, error) {
k, ok := v.(string)
if !ok {
return "", httpErr(400, "Неверный публичный ключ")
}
return checkPubKey(k)
}
func checkPubKeys(arr []any) ([]string, error) {
out := make([]string, 0, len(arr))
for _, v := range arr {
k, err := checkPubKeyAny(v)
if err != nil {
return nil, err
}
out = append(out, k)
}
return out, nil
}
// jsToNumber — Number(v) для значений из JSON.
func jsToNumber(v any) float64 {
switch x := v.(type) {
case nil:
return 0
case float64:
return x
case bool:
if x {
return 1
}
return 0
case string:
return jsNumber(x)
}
return math.NaN()
}
func (s *Server) knownPubKeys() (map[string]bool, error) {
known := map[string]bool{}
rows, err := s.db.Query("SELECT pub_key FROM users")
if err != nil {
return nil, err
}
for rows.Next() {
var k string
rows.Scan(&k)
known[k] = true
}
rows.Close()
peers, err := s.peersAll()
if err != nil {
return nil, err
}
for _, p := range peers {
known[p.PubKey] = true
}
return known, nil
}
func (s *Server) isKnownPub(k string) (bool, error) {
if p, err := s.peerGet(k); err != nil || p != nil {
return p != nil, err
}
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE pub_key = ?", k).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// profileAwg — параметры для /api/profile в порядке CONFIG.awgParams (порядок объявления в
// DEFAULT_AWG_PARAMS), без пустых значений; числа — строками.
func profileAwg(p Params) jsjson.Object {
o := jsjson.Object{}
add := func(k, v string) {
if v != "" {
o = append(o, jsjson.KV{Key: k, Val: v})
}
}
itoa := strconv.Itoa
add("Jc", itoa(p.Jc))
add("Jmin", itoa(p.Jmin))
add("Jmax", itoa(p.Jmax))
add("S1", itoa(p.S1))
add("S2", itoa(p.S2))
add("S3", itoa(p.S3))
add("S4", itoa(p.S4))
add("H1", p.H1)
add("H2", p.H2)
add("H3", p.H3)
add("H4", p.H4)
add("I1", p.I1)
add("I2", p.I2)
add("I3", p.I3)
add("I4", p.I4)
add("I5", p.I5)
for _, k := range AWG3Keys {
add(k, p.A3[k])
}
return o
}
var _ = strings.TrimSpace
+236
View File
@@ -0,0 +1,236 @@
package ctrl
import (
"fmt"
"io"
"mime"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Минимальный роутер с поведением Express 4, на которое опираются клиенты:
// - тело JSON разбирается ДО авторизации и маршрутизации (битый JSON без токена → 400, не 401);
// - ошибки разбора и неизвестные пути отвечают HTML, как finalhandler Express;
// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется.
// Первое совпавшее объявление выигрывает — порядок ниже важен («reissue» до «:name», «sync» до «:pub»).
type rctx struct {
w http.ResponseWriter
r *http.Request
s *Server
params map[string]string
body any // jsjson.Object, []any или nil (= {})
}
type result struct {
status int
v any
}
type route struct {
method string
segs []string
mw []func(*rctx) error
lock bool
h func(*rctx) (result, error)
}
const (
bodyLimitSmall = 1024
bodyLimitBig = 512 * 1024
)
func writeJSON(w http.ResponseWriter, status int, v any) {
b, err := jsjson.Marshal(v)
if err != nil {
status, b = 500, []byte(`{"error":"Internal server error"}`)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(b)
}
func writeHTMLError(w http.ResponseWriter, status int, text string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>%s</pre>\n</body>\n</html>\n", text)
}
// readBody — express.json({limit}): только application/json, объект или массив.
func readBody(r *http.Request, limit int64) (any, int, string) {
mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mt != "application/json" || r.ContentLength == 0 && r.Body == http.NoBody {
return nil, 0, ""
}
if r.ContentLength > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
if err != nil {
return nil, 400, "BadRequestError: request aborted"
}
if int64(len(raw)) > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace)
if trimmed == "" {
return nil, 0, ""
}
if trimmed[0] != '{' && trimmed[0] != '[' {
return nil, 400, "SyntaxError: Unexpected token in JSON"
}
v, err := jsjson.Decode(raw)
if err != nil {
return nil, 400, "SyntaxError: " + err.Error()
}
return v, 0, ""
}
func (c *rctx) obj() jsjson.Object {
o, _ := c.body.(jsjson.Object)
return o
}
func match(rt route, method string, segs []string) map[string]string {
if rt.method != method || len(rt.segs) != len(segs) {
return nil
}
params := map[string]string{}
for i, p := range rt.segs {
if strings.HasPrefix(p, ":") {
v, ok := unescape(segs[i])
if !ok {
return nil
}
params[p[1:]] = v
} else if !strings.EqualFold(p, segs[i]) {
return nil
}
}
return params
}
func unescape(s string) (string, bool) {
if !strings.Contains(s, "%") {
return s, true
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+2 < len(s)+0 && isHex(s[i+1]) && isHex(s[i+2]) {
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
} else if s[i] == '%' {
return "", false
} else {
b.WriteByte(s[i])
}
}
return b.String(), true
}
func isHex(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
func unhex(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
}
return c - 'A' + 10
}
func (s *Server) handler() http.Handler {
routes := s.routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Тело разбирается первым и для всех путей; лимит выбирает точный путь.
limit := int64(bodyLimitSmall)
p := strings.TrimSuffix(r.URL.EscapedPath(), "/")
if strings.EqualFold(p, "/api/peers/sync") || strings.EqualFold(p, "/api/audit/clean") {
limit = bodyLimitBig
}
body, status, msg := readBody(r, limit)
if status != 0 {
writeHTMLError(w, status, msg)
return
}
segs := strings.Split(strings.Trim(r.URL.EscapedPath(), "/"), "/")
for _, rt := range routes {
params := match(rt, r.Method, segs)
if params == nil {
continue
}
c := &rctx{w: w, r: r, s: s, params: params, body: body}
s.serve(c, rt)
return
}
writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.Path)
})
}
func (s *Server) serve(c *rctx, rt route) {
defer func() {
if p := recover(); p != nil {
s.log.Error("handler panic", "error", fmt.Sprint(p))
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
}()
for _, mw := range rt.mw {
if err := mw(c); err != nil {
s.fail(c, err)
return
}
}
if rt.lock {
s.mu.Lock()
defer s.mu.Unlock()
}
res, err := rt.h(c)
if err != nil {
s.fail(c, err)
return
}
writeJSON(c.w, res.status, res.v)
}
func (s *Server) fail(c *rctx, err error) {
if he, ok := err.(*HTTPError); ok {
writeJSON(c.w, he.Status, jsjson.Object{{Key: "error", Val: he.Msg}})
return
}
s.log.Error("handler error", "error", err)
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
// ───────── middleware ─────────
func (s *Server) auth(c *rctx) error {
h := c.r.Header.Get("Authorization")
tok := ""
if strings.HasPrefix(h, "Bearer ") {
tok = h[7:]
}
if tok == "" || !iauth.Verify(s.pub, tok, nowFn()) {
return httpErr(401, "Неверная авторизация")
}
return nil
}
var reName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`)
func validateName(c *rctx) error {
name, ok := c.params["name"]
if !ok {
name, _ = c.obj().Get2("name").(string)
}
if name == "" || !reName.MatchString(name) {
return httpErr(400, "Имя: буквы, цифры, _ и -, до 32 символов")
}
return nil
}
+219
View File
@@ -0,0 +1,219 @@
package ctrl
import (
"encoding/binary"
"encoding/hex"
"math/rand/v2"
"regexp"
"strconv"
"strings"
)
// I1 — «сигнатурный» пакет, который клиент шлёт перед рукопожатием, чтобы начало сессии
// выглядело как другой протокол. Сервер I-пакеты отбрасывает (они не совпадают ни с одним
// H-заголовком), поэтому совпадать у сервера и клиента I1 не обязан: выданные vpn:// ключи со
// старым I1 продолжают подключаться, а смена касается только новых конфигов.
//
// Поэтому I1 живёт не в awg1.conf, а в БД awg-ctrl (config.i1): в ядро он не уходит — нет
// риска, что модуль не примет шаблон и интерфейс не поднимется, — и переживает KEEP_DATA вместе
// с базой. Если I1 вписан в awg1.conf руками, на первом старте он и берётся.
//
// Шаблон — один из нескольких правдоподобных (DNS-запрос, начало QUIC Initial, STUN binding
// request) со случайными полями: у каждого сервера свой, общий для всех I1 был бы сигнатурой.
// Только теги <b 0x…> и <r N>: их понимают все клиенты с I1–I5. Идентификаторы транзакций и
// соединений — <r N>, они случайны в каждом пакете, как у настоящих протоколов.
const cfgI1 = "i1"
// Популярные домены, запросы к которым в любой сети выглядят обычно. Заблокированных в РФ
// нет: запрос к ним сам по себе привлекал бы внимание.
var i1Domains = []string{
"google.com", "www.google.com", "youtube.com", "gstatic.com", "googleapis.com",
"apple.com", "icloud.com", "microsoft.com", "bing.com", "live.com", "cloudflare.com",
"amazon.com", "wikipedia.org", "github.com", "yandex.ru", "ya.ru", "vk.com", "mail.ru",
"ok.ru", "avito.ru", "ozon.ru", "wildberries.ru", "gosuslugi.ru", "dzen.ru", "2gis.ru",
}
// GenI1 — случайный I1 из одного из шаблонов.
func GenI1() string {
switch rand.IntN(3) {
case 0:
return i1DNS()
case 1:
return i1QUIC()
default:
return i1STUN()
}
}
func pick[T any](v ...T) T { return v[rand.IntN(len(v))] }
// DNS-запрос: ID — <r 2>, флаги RD, один вопрос A/AAAA/HTTPS, иногда EDNS0 (как у systemd-resolved,
// браузеров и роутеров).
func i1DNS() string {
var b []byte
edns := rand.IntN(2) == 0
ar := uint16(0)
if edns {
ar = 1
}
b = binary.BigEndian.AppendUint16(b, 0x0100) // флаги: стандартный запрос, RD
b = append(b, 0, 1, 0, 0, 0, 0) // QDCOUNT 1, ANCOUNT 0, NSCOUNT 0
b = binary.BigEndian.AppendUint16(b, ar) // ARCOUNT
for _, l := range strings.Split(pick(i1Domains...), ".") { // QNAME
b = append(b, byte(len(l)))
b = append(b, l...)
}
b = append(b, 0)
b = binary.BigEndian.AppendUint16(b, pick[uint16](1, 1, 28, 65)) // QTYPE: A чаще, AAAA, HTTPS
b = append(b, 0, 1) // QCLASS IN
if edns {
b = append(b, 0, 0, 41) // OPT, имя — корень
b = binary.BigEndian.AppendUint16(b, pick[uint16](1232, 4096, 1400))
b = append(b, 0, 0) // расширенный RCODE, версия
b = binary.BigEndian.AppendUint16(b, pick[uint16](0, 0x8000)) // флаг DO
b = append(b, 0, 0) // RDLEN
}
return "<r 2><b 0x" + hex.EncodeToString(b) + ">"
}
// Начало QUIC v1 Initial: длинный заголовок (младшие биты первого байта под header protection —
// случайны), DCID 8 байт, SCID 0 или 8, без токена; остаток до 1200/1250 байт — шифрованная
// полезная нагрузка, её изображает <r N>.
func i1QUIC() string {
scid := pick(0, 8)
total := pick(1200, 1250, 1252)
head := 1 + 4 + 1 + 8 + 1 + scid + 1 + 2
rest := total - head
s := "<b 0x" + hex.EncodeToString([]byte{byte(0xc0 | rand.IntN(16)), 0, 0, 0, 1, 8}) + "><r 8>"
if scid > 0 {
s += "<b 0x08><r 8>"
} else {
s += "<b 0x00>"
}
return s + "<b 0x00" + hex.EncodeToString(binary.BigEndian.AppendUint16(nil, 0x4000|uint16(rest))) + "><r " + strconv.Itoa(rest) + ">"
}
// STUN binding request (RFC 5389): magic cookie, ID транзакции — <r 12>; иногда с
// CHANGE-REQUEST (проверка NAT, RFC 5780).
func i1STUN() string {
attrs := pick([]byte{}, []byte{0, 3, 0, 4, 0, 0, 0, 0})
b := []byte{0, 1}
b = binary.BigEndian.AppendUint16(b, uint16(len(attrs)))
b = append(b, 0x21, 0x12, 0xa4, 0x42)
s := "<b 0x" + hex.EncodeToString(b) + "><r 12>"
if len(attrs) > 0 {
s += "<b 0x" + hex.EncodeToString(attrs) + ">"
}
return s
}
var reI1Tag = regexp.MustCompile(`<(b|r) ([^>]*)>`)
// i1Bytes раскрывает шаблон в байты (<r N> — нули); ok=false для тегов, кроме <b>/<r>.
func i1Bytes(s string) ([]byte, bool) {
var out []byte
rest := strings.TrimSpace(s)
for rest != "" {
m := reI1Tag.FindStringSubmatchIndex(rest)
if m == nil || m[0] != 0 {
return nil, false
}
kind, val := rest[m[2]:m[3]], rest[m[4]:m[5]]
if kind == "b" {
h, err := hex.DecodeString(strings.TrimPrefix(val, "0x"))
if err != nil {
return nil, false
}
out = append(out, h...)
} else {
n, err := strconv.Atoi(val)
if err != nil || n < 0 || n > 65535 {
return nil, false
}
out = append(out, make([]byte, n)...)
}
rest = rest[m[1]:]
}
return out, len(out) > 0
}
// dnsName — имя первого вопроса DNS-пакета.
func dnsName(b []byte) string {
var parts []string
for i := 12; i < len(b) && b[i] != 0; {
n := int(b[i])
if n > 63 || i+1+n > len(b) {
return ""
}
parts = append(parts, string(b[i+1:i+1+n]))
i += 1 + n
}
return strings.Join(parts, ".")
}
// DescribeI1 — под что маскируется I1, для панели: вид (dns, quic, stun, custom, none) и подпись.
func DescribeI1(s string) (kind, label string) {
if strings.TrimSpace(s) == "" {
return "none", "без маскировки"
}
b, ok := i1Bytes(s)
switch {
case !ok:
case len(b) >= 20 && binary.BigEndian.Uint32(b[4:8]) == 0x2112a442 && b[0] == 0 && b[1] == 1:
return "stun", "STUN binding request"
case len(b) >= 6 && b[0]&0xc0 == 0xc0 && binary.BigEndian.Uint32(b[1:5]) == 1:
return "quic", "QUIC Initial, " + strconv.Itoa(len(b)) + " байт"
case len(b) > 12 && b[2]&0x80 == 0 && binary.BigEndian.Uint16(b[4:6]) == 1:
if n := dnsName(b); n != "" {
return "dns", "DNS-запрос к " + n
}
case len(b) > 12 && b[2]&0x80 != 0:
if n := dnsName(b); n != "" {
return "dns", "DNS-ответ от " + n
}
}
return "custom", "свой шаблон"
}
// i1FromConf — I1, вписанный в [Interface] awg1.conf руками (ReadParams без него отдаёт
// значение по умолчанию).
func i1FromConf(confFile string) string {
p := ReadParams(confFile, nil)
if p.I1 == DefaultParams().I1 {
return ""
}
return p.I1
}
// ensureI1 — на старте: I1 этого сервера из БД; нет — из awg1.conf, нет и там — новый случайный.
func (s *Server) ensureI1() string {
if v := s.getCfg(cfgI1, ""); v != "" {
return v
}
v := i1FromConf(s.confPath())
if v == "" {
v = GenI1()
}
s.setCfg(cfgI1, v)
kind, label := DescribeI1(v)
s.log.Info("I1 assigned", "kind", kind, "label", label)
return v
}
// regenI1 — новый случайный I1. Клиенты по sen:// получают его со следующим обновлением
// профиля; vpn:// ключи несут прежний и продолжают работать, пока их не перевыпустят.
func (s *Server) regenI1() string {
v := GenI1()
s.setCfg(cfgI1, v)
s.cfgMu.Lock()
s.c.Params.I1 = v
s.cfgMu.Unlock()
kind, label := DescribeI1(v)
s.log.Info("I1 regenerated", "kind", kind, "label", label)
return v
}
func maskingInfo(i1 string) ob {
kind, label := DescribeI1(i1)
return ob{{"i1", i1}, {"kind", kind}, {"label", label}}
}
+58
View File
@@ -0,0 +1,58 @@
package ctrl
import (
"strings"
"testing"
)
func TestGenI1Templates(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 500; i++ {
v := GenI1()
b, ok := i1Bytes(v)
if !ok {
t.Fatalf("шаблон не разбирается: %q", v)
}
kind, label := DescribeI1(v)
seen[kind] = true
switch kind {
case "dns":
if !strings.HasPrefix(v, "<r 2>") || !strings.HasPrefix(label, "DNS-запрос к ") {
t.Fatalf("DNS: %q → %q", v, label)
}
case "quic":
if n := len(b); n != 1200 && n != 1250 && n != 1252 {
t.Fatalf("QUIC длиной %d: %q", n, v)
}
case "stun":
if len(b) != 20 && len(b) != 28 {
t.Fatalf("STUN длиной %d: %q", len(b), v)
}
default:
t.Fatalf("неожиданный вид %q: %q", kind, v)
}
// Только <b>/<r>, без пробелов вне тегов — так I1 пишут и клиенты AmneziaVPN.
if strings.Contains(strings.NewReplacer("<b ", "", "<r ", "").Replace(v), " ") {
t.Fatalf("лишние пробелы: %q", v)
}
}
for _, k := range []string{"dns", "quic", "stun"} {
if !seen[k] {
t.Fatalf("шаблон %s ни разу не выпал", k)
}
}
}
func TestDescribeI1(t *testing.T) {
cases := map[string]string{
DefaultParams().I1: "DNS-ответ от icloud.com",
"": "без маскировки",
"<c><t>": "свой шаблон",
"<b 0xdeadbeef>": "свой шаблон",
}
for in, want := range cases {
if _, got := DescribeI1(in); got != want {
t.Errorf("DescribeI1(%q) = %q, want %q", in, got, want)
}
}
}
+189
View File
@@ -0,0 +1,189 @@
package ctrl
import (
"os"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с —
// несколько скрейперов не должны гонять `awg show` чаще.
const metricsTTL = 5 * time.Second
type metricsCache struct {
mu sync.Mutex
at time.Time
data any
}
type metricsPeer struct {
Kind string `json:"kind"`
Name string `json:"name"`
IP string `json:"ip"`
Online bool `json:"online"`
Rx float64 `json:"rx"`
Tx float64 `json:"tx"`
Priority string `json:"priority"`
Torrent bool `json:"torrent"`
}
type metricsHost struct {
CPU jsjson.Object `json:"cpu"` // порядок режимов как в /proc/stat (и в Node-версии)
Cores int `json:"cores"`
Load []float64 `json:"load"`
Mem struct {
Total float64 `json:"total"`
Available float64 `json:"available"`
} `json:"mem"`
}
type linkStats struct {
RxBytes float64 `json:"rx_bytes"`
TxBytes float64 `json:"tx_bytes"`
RxPackets float64 `json:"rx_packets"`
TxPackets float64 `json:"tx_packets"`
RxDropped float64 `json:"rx_dropped"`
TxDropped float64 `json:"tx_dropped"`
RxErrors float64 `json:"rx_errors"`
TxErrors float64 `json:"tx_errors"`
}
type metricsBody struct {
Up bool `json:"up"`
Gen string `json:"gen"`
Peers struct {
Total int `json:"total"`
Online int `json:"online"`
} `json:"peers"`
RxBytes float64 `json:"rx_bytes"`
TxBytes float64 `json:"tx_bytes"`
Link linkStats `json:"link"`
Shaping ShapingStatus `json:"shaping"`
Torrent TorrentStatus `json:"torrent"`
Host *metricsHost `json:"host"`
List []metricsPeer `json:"list"`
}
func (s *Server) linkCounter(name string) float64 {
b, err := os.ReadFile("/sys/class/net/" + s.iface + "/statistics/" + name)
if err != nil {
return 0
}
return orZero(jsNumber(string(b)))
}
var cpuModes = []string{"user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"}
// readHost: нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у
// node_exporter): долю считает Prometheus через rate(). Память — MemAvailable, а не MemFree:
// кэш ядра свободен.
func readHost() *metricsHost {
stat, err := os.ReadFile("/proc/stat")
if err != nil {
return nil
}
first := strings.Fields(strings.SplitN(string(stat), "\n", 2)[0])
if len(first) < 2 {
return nil
}
h := &metricsHost{Cores: runtime.NumCPU()}
for i, m := range cpuModes {
v := 0.0
if i+1 < len(first) {
v = orZero(jsNumber(first[i+1]))
}
h.CPU = append(h.CPU, jsjson.KV{Key: m, Val: v / 100}) // USER_HZ = 100 на всех обычных ядрах
}
mem, err := os.ReadFile("/proc/meminfo")
if err != nil {
return nil
}
kb := func(k string) float64 {
m := regexp.MustCompile(`(?m)^` + k + `:\s+(\d+) kB`).FindStringSubmatch(string(mem))
if m == nil {
return 0
}
f, _ := strconv.ParseFloat(m[1], 64)
return f * 1024
}
h.Mem.Total, h.Mem.Available = kb("MemTotal"), kb("MemAvailable")
if la, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(la))
for i := 0; i < 3 && i < len(f); i++ {
h.Load = append(h.Load, orZero(jsNumber(f[i])))
}
}
return h
}
func (s *Server) collectMetrics() (*metricsBody, error) {
st := s.interfaceStatus()
stats := map[string]peerStat{}
if st.Up {
stats = s.getPeersData()
}
torrent := s.torrent.SeenSet() // до запроса: внутри цикла по строкам — ни базы, ни nft
rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''")
if err != nil {
return nil, err
}
b := &metricsBody{Up: st.Up, Gen: s.cfg().Gen, List: []metricsPeer{}}
for rows.Next() {
var n, ip, pub string
if err := rows.Scan(&n, &ip, &pub); err != nil {
rows.Close()
return nil, err
}
ps := stats[pub]
b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip), torrent[ip]})
}
rows.Close()
devs, err := s.peersAll()
if err != nil {
return nil, err
}
for _, p := range devs {
ps := stats[p.PubKey]
b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP), torrent[p.IP]})
}
b.Peers.Total = st.Peers
for _, ps := range stats {
if ps.Online {
b.Peers.Online++
}
b.RxBytes += orZero(ps.Rx)
b.TxBytes += orZero(ps.Tx)
}
b.Link = linkStats{
s.linkCounter("rx_bytes"), s.linkCounter("tx_bytes"), s.linkCounter("rx_packets"), s.linkCounter("tx_packets"),
s.linkCounter("rx_dropped"), s.linkCounter("tx_dropped"), s.linkCounter("rx_errors"), s.linkCounter("tx_errors"),
}
b.Shaping = s.shaping.Status()
b.Torrent = s.torrent.Status()
b.Host = readHost()
return b, nil
}
// metricsCached отдаёт кэш на metricsTTL; ошибку не кэшируем.
func (s *Server) metricsCached() (any, error) {
m := &s.metrics
m.mu.Lock()
defer m.mu.Unlock()
if m.data != nil && time.Since(m.at) <= metricsTTL {
return m.data, nil
}
d, err := s.collectMetrics()
if err != nil {
m.data = nil
return nil, err
}
m.at, m.data = time.Now(), d
return d, nil
}
+37
View File
@@ -0,0 +1,37 @@
package ctrl
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/maeneko/forgetting/internal/logx"
)
// SQLite открыт с одним соединением: любой запрос в базу изнутри цикла по строкам users ждал
// бы его вечно и намертво вешал awg-ctrl (так было в 0.4.2 с меткой торрента). Скрейп метрик
// идёт раз в 15 с, поэтому зависание проявлялось сразу.
func TestCollectMetricsNoDeadlock(t *testing.T) {
db, err := openDB(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES (?, ?, ?, 'vpn://x')", fmt.Sprintf("u%d", i), fmt.Sprintf("10.9.0.%d", i+2), fmt.Sprintf("pk%d", i))
}
s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())}
s.shaping = newShaping(s)
s.torrent = newTorrent(s)
done := make(chan error, 1)
go func() { _, err := s.collectMetrics(); done <- err }()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("collectMetrics завис: запрос в базу изнутри цикла по строкам")
}
}
+104
View File
@@ -0,0 +1,104 @@
package ctrl
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
)
// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие
// одно из двух и без параметров: awg-ctrl запускает ту же команду, что оператор набрал бы
// руками (`awg-ctrl monitor install|remove`, root), так что код один. Панель сама пакетов
// не ставит и не удаляет — она только просит об этом здесь.
type monitorJob struct {
mu sync.Mutex
active bool
action string
exit *int
}
var reANSI = regexp.MustCompile("\x1b\\[[0-9;]*[A-Za-z]")
func (s *Server) logTail() string {
b, err := os.ReadFile(s.cliLog)
if err != nil {
return ""
}
t := string(b)
if len(t) > 4000 { // последние 4000 символов (в JS — UTF-16 единиц; для лога разницы нет)
t = t[len(t)-4000:]
}
return strings.ReplaceAll(reANSI.ReplaceAllString(t, ""), "\r", "\n")
}
// startMonitorJob запускает CLI в фоне; возвращает 409, если уже идёт.
func (s *Server) startMonitorJob(action string) error {
j := &s.monitor
j.mu.Lock()
if j.active {
j.mu.Unlock()
return httpErr(409, "Операция уже идёт")
}
if _, err := os.Stat(s.cliPath); err != nil {
j.mu.Unlock()
return httpErr(501, "CLI не найден ("+s.cliPath+"): выполните на сервере awg-ctrl monitor "+action)
}
os.MkdirAll(filepath.Dir(s.cliLog), 0o755)
f, err := os.OpenFile(s.cliLog, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
j.mu.Unlock()
return err
}
cmd := exec.Command(s.cliPath, "monitor", action)
cmd.Stdout, cmd.Stderr = f, f
j.active, j.action, j.exit = true, action, nil
if err := cmd.Start(); err != nil {
s.log.Error("monitor: не запустился", "action", action, "error", err.Error())
code := 127
j.active, j.exit = false, &code
f.Close()
j.mu.Unlock()
return nil
}
j.mu.Unlock()
go func() {
err := cmd.Wait()
code := 0
if err != nil {
code = 1
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
}
}
f.Close()
s.log.Info("monitor: завершён", "action", action, "code", code)
j.mu.Lock()
j.active, j.exit = false, &code
j.mu.Unlock()
}()
return nil
}
type jobView struct {
Action *string `json:"action"`
Running bool `json:"running"`
Exit *int `json:"exit"`
Log string `json:"log"`
}
func (s *Server) monitorView() jobView {
j := &s.monitor
j.mu.Lock()
defer j.mu.Unlock()
v := jobView{Running: j.active, Exit: j.exit}
if j.action != "" {
a := j.action
v.Action = &a
v.Log = s.logTail()
}
return v
}
+605
View File
@@ -0,0 +1,605 @@
package ctrl
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
// HTTPError — ошибка с кодом ответа; любая другая ошибка превращается в 500.
type HTTPError struct {
Status int
Msg string
}
func (e *HTTPError) Error() string { return e.Msg }
func httpErr(status int, msg string) error { return &HTTPError{status, msg} }
// ───────── пользователи (vpn://) ─────────
func (s *Server) addUser(name string) (*userRow, error) {
keys, err := s.generateKeys()
if err != nil {
return nil, err
}
serverPub, err := s.getServerPublicKey()
if err != nil {
return nil, err
}
cfg := s.cfg()
var ip string
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
ip, err = s.nextIp(tx, cfg.Subnet)
if err == nil {
_, err = tx.Exec("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)",
name, ip, keys.Public, "", keys.PSK, cfg.Gen)
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
vpnKey, err := EncodeVpnKey(s.keyEnv(), keys, ip, serverPub)
if err != nil {
return nil, err
}
if _, err := s.db.Exec("UPDATE users SET vpn_key = ? WHERE name = ?", vpnKey, name); err != nil {
return nil, err
}
if r := s.setPeer(keys.Public, keys.PSK, ip); !r.ok() {
s.db.Exec("DELETE FROM users WHERE name = ?", name)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("user created", "name", name, "ip", ip, "gen", cfg.Gen)
return &userRow{Name: name, IP: ip, PubKey: keys.Public, VpnKey: vpnKey, PskKey: keys.PSK, KeyGen: cfg.Gen}, nil
}
func (s *Server) removeUser(name string) error {
u, err := s.userGet(name)
if err != nil {
return err
}
if u == nil {
return errors.New("Пользователь не найден") // в Node — обычный Error → 500
}
if !s.dropFromKernel(u.PubKey) {
return errors.New("Не удалось снять пира с интерфейса")
}
if _, err := s.db.Exec("DELETE FROM users WHERE name = ?", name); err != nil {
return err
}
if err := s.rebuildConf(); err != nil {
return err
}
for _, ext := range []string{".conf", ".key"} {
f := filepath.Join(s.confDir, "clients", name+ext)
if _, err := os.Stat(f); err == nil {
os.Remove(f)
}
}
s.log.Info("user removed", "name", name)
return nil
}
type reissueResult struct {
Total int `json:"total"`
Reissued int `json:"reissued"`
Regenerated []string `json:"regenerated"`
Backup string `json:"backup"`
}
// reissueAll перевыпускает все vpn:// ключи на текущих параметрах интерфейса — после смены
// поколения старые перестают работать. IP, pub_key и psk_key сохраняются: на проводе ничего
// не меняется, клиенту достаточно заново импортировать ключ.
func (s *Server) reissueAll() (*reissueResult, error) {
users, err := s.usersAll()
if err != nil {
return nil, err
}
serverPub, err := s.getServerPublicKey()
if err != nil {
return nil, err
}
backup := s.dbPath + ".bak-" + jsonStamp(time.Now())
if _, err := s.db.Exec("VACUUM INTO ?", backup); err != nil {
return nil, err
}
s.log.Info("reissue: db backed up", "backup", backup, "users", len(users))
res := &reissueResult{Total: len(users), Regenerated: []string{}, Backup: backup}
cfg := s.cfg()
for _, u := range users {
var keys Keys
if priv := clientPrivKeyFrom(u.VpnKey); priv != "" {
keys = Keys{Private: priv, Public: u.PubKey, PSK: u.PskKey}
} else {
// Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую.
s.log.Warn("reissue: vpn_key не декодируется, генерируем новую пару", "name", u.Name)
keys, err = s.generateKeys()
if err != nil {
return nil, err
}
runCmd("awg", "set", s.iface, "peer", u.PubKey, "remove")
if r := s.setPeer(keys.Public, keys.PSK, u.IP); !r.ok() {
s.log.Error("reissue: awg set failed", "name", u.Name, "stderr", r.Stderr)
continue
}
res.Regenerated = append(res.Regenerated, u.Name)
}
vk, err := EncodeVpnKey(s.keyEnv(), keys, u.IP, serverPub)
if err != nil {
return nil, err
}
if _, err := s.db.Exec("UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?",
vk, keys.Public, keys.PSK, cfg.Gen, u.Name); err != nil {
return nil, err
}
res.Reissued++
}
if err := s.syncPeers(); err != nil {
return nil, err
}
s.log.Info("reissue: done", "total", len(users), "reissued", res.Reissued, "regenerated", len(res.Regenerated))
return res, nil
}
// ───────── пиры устройств (sen://) ─────────
// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим публичным
// ключом». Приватного ключа устройства здесь нет и быть не может.
var rePub = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`)
// checkPubKey: base64 от 32 байт (44 символа, последний «=»).
func checkPubKey(k string) (string, error) {
if !rePub.MatchString(k) {
return "", httpErr(400, "Неверный публичный ключ")
}
b, err := b64Std(k)
if err != nil || len(b) != 32 {
return "", httpErr(400, "Неверный публичный ключ")
}
return k, nil
}
// pubKeyFromParam: в пути base64url — «/» и «+» обычного base64 ломают маршрут.
func pubKeyFromParam(p string) (string, error) {
b, err := lenientB64URL(p)
if err != nil {
return "", httpErr(400, "Неверный публичный ключ")
}
return checkPubKey(stdB64(b))
}
type ipPsk struct {
IP string `json:"ip"`
PSK string `json:"psk_key"`
}
func (s *Server) assertPubKeyFree(q queryer, pub string) error {
var n int
if err := q.QueryRow("SELECT COUNT(*) FROM peers WHERE pub_key = ?", pub).Scan(&n); err != nil {
return err
}
taken, err := s.userPubTaken(q, pub)
if err != nil {
return err
}
if n > 0 || taken {
return httpErr(409, "Публичный ключ уже используется")
}
if sp, err := s.getServerPublicKey(); err != nil {
return err
} else if pub == sp {
return httpErr(409, "Публичный ключ уже используется")
}
return nil
}
func (s *Server) addPeer(pub, owner string) (*ipPsk, error) {
if _, err := checkPubKey(pub); err != nil {
return nil, err
}
psk, err := s.genPSK()
if err != nil {
return nil, err
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
var ip string
err = s.assertPubKeyFree(tx, pub)
if err == nil {
ip, err = s.nextIp(tx, s.cfg().Subnet)
}
if err == nil {
_, err = tx.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)",
pub, ip, psk, owner, time.Now().Unix())
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
if r := s.setPeer(pub, psk, ip); !r.ok() {
s.db.Exec("DELETE FROM peers WHERE pub_key = ?", pub)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer added", "owner", owner, "ip", ip)
return &ipPsk{ip, psk}, nil
}
// replacePeer — смена ключа устройства: IP и PSK остаются, меняется только pub_key.
func (s *Server) replacePeer(oldPub, newPub string) (*ipPsk, error) {
if _, err := checkPubKey(newPub); err != nil {
return nil, err
}
old, err := s.peerGet(oldPub)
if err != nil {
return nil, err
}
if old == nil {
return nil, httpErr(404, "Пир не найден")
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
if err = s.assertPubKeyFree(tx, newPub); err == nil {
_, err = tx.Exec("UPDATE peers SET pub_key = ? WHERE pub_key = ?", newPub, oldPub)
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
if r := s.setPeer(newPub, old.PskKey, old.IP); !r.ok() {
s.db.Exec("UPDATE peers SET pub_key = ? WHERE pub_key = ?", oldPub, newPub)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
// Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа
// не откатываем: новый уже стоит, а клиент к этому моменту получил бы ошибку.
if !s.dropFromKernel(oldPub) {
s.log.Error("peer rekey: старый ключ остался в ядре", "owner", old.Owner)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer rekeyed", "owner", old.Owner, "ip", old.IP)
return &ipPsk{old.IP, old.PskKey}, nil
}
func (s *Server) rotatePeerPSK(pub string) (*ipPsk, error) {
p, err := s.peerGet(pub)
if err != nil {
return nil, err
}
if p == nil {
return nil, httpErr(404, "Пир не найден")
}
psk, err := s.genPSK()
if err != nil {
return nil, err
}
if r := s.setPeer(pub, psk, p.IP); !r.ok() {
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if _, err := s.db.Exec("UPDATE peers SET psk_key = ? WHERE pub_key = ?", psk, pub); err != nil {
return nil, err
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer psk rotated", "owner", p.Owner)
return &ipPsk{p.IP, psk}, nil
}
func (s *Server) removePeer(pub string) error {
p, err := s.peerGet(pub)
if err != nil {
return err
}
if p == nil {
return httpErr(404, "Пир не найден")
}
if !s.dropFromKernel(pub) {
return errors.New("Не удалось снять пира с интерфейса")
}
if _, err := s.db.Exec("DELETE FROM peers WHERE pub_key = ?", pub); err != nil {
return err
}
if err := s.rebuildConf(); err != nil {
return err
}
s.log.Info("peer removed", "owner", p.Owner)
return nil
}
var (
rePanelID = regexp.MustCompile(`^[a-z0-9]{8,16}$`)
reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`)
)
const syncMax = 10_000
type legacyPeer struct {
PubKey string `json:"pub_key"`
Owner string `json:"owner"`
IP string `json:"ip"`
}
type syncResult struct {
Present []string `json:"present"`
Removed int `json:"removed"`
Legacy []legacyPeer `json:"legacy"`
}
// syncOwnedPeers: панель присылает полный список ключей, которые ей нужны на этом сервере,
// — всё остальное из её области («<panel>/…») снимается. Удаляем только по такому явному
// списку, никогда по времени или неактивности: панель не на связи — ничего не происходит.
// Пиры без области (owner «m1/d2», выданы до областей) панель забирает себе, только если их
// ключ есть в её списке; остальные не трогаем — чьи они, уже не разобрать.
func (s *Server) syncOwnedPeers(panel string, keep []string) (*syncResult, error) {
want := map[string]bool{}
for _, k := range keep {
want[k] = true
}
prefix := panel + "/"
peers, err := s.peersAll()
if err != nil {
return nil, err
}
var removed []peerRow
claimed := 0
for _, p := range peers {
switch {
case strings.HasPrefix(p.Owner, prefix):
// Ядро первым: не снялся — строка остаётся, следующий sync попробует снова.
if !want[p.PubKey] && s.dropFromKernel(p.PubKey) {
if _, err := s.db.Exec("DELETE FROM peers WHERE pub_key = ?", p.PubKey); err != nil {
return nil, err
}
removed = append(removed, p)
}
case reLegacyOwner.MatchString(p.Owner) && want[p.PubKey]:
if _, err := s.db.Exec("UPDATE peers SET owner = ? WHERE pub_key = ?", prefix+p.Owner, p.PubKey); err != nil {
return nil, err
}
claimed++
}
}
if len(removed) > 0 || claimed > 0 {
if err := s.rebuildConf(); err != nil {
return nil, err
}
}
if len(removed) > 0 {
owners := make([]string, len(removed))
for i, p := range removed {
owners[i] = p.Owner
}
s.log.Info("peers synced out", "panel", panel, "removed", owners)
}
all, err := s.peersAll()
if err != nil {
return nil, err
}
res := &syncResult{Present: []string{}, Removed: len(removed), Legacy: []legacyPeer{}}
for _, p := range all {
if strings.HasPrefix(p.Owner, prefix) {
res.Present = append(res.Present, p.PubKey)
}
if reLegacyOwner.MatchString(p.Owner) {
res.Legacy = append(res.Legacy, legacyPeer{p.PubKey, p.Owner, p.IP})
}
}
return res, nil
}
// ───────── переход 2.0 → 3.1 ─────────
// Header protection использует библиотечный chacha-API, которого нет до ядра 5.5 — там
// модуль 3.x просто не соберётся (upstream issue #210).
func kernelRelease() string {
r := runCmd("uname", "-r")
return strings.TrimSpace(r.Stdout)
}
func kernelSupportsHeaderProtection(release string) bool {
parts := strings.Split(release, ".")
if len(parts) < 2 {
return false
}
maj, e1 := strconv.Atoi(leadingDigits(parts[0]))
min, e2 := strconv.Atoi(leadingDigits(parts[1]))
if e1 != nil || e2 != nil {
return false
}
return maj > 5 || (maj == 5 && min >= 5)
}
func leadingDigits(s string) string {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
return s[:i]
}
// checkGen31Support решает ЗАГРУЖЕННЫЙ модуль, а не тот, что лежит на диске после apt.
func (s *Server) checkGen31Support() string {
module, _ := readVersions()
if !strings.HasPrefix(module, "3.") {
m := module
if m == "" {
m = "неизвестной версии"
}
return fmt.Sprintf("В ядре загружен модуль amneziawg %s — для 3.1 нужна 3.x. ", m) +
"Обнови пакет (apt) и перезагрузи сервер, затем повтори переход."
}
if rel := kernelRelease(); !kernelSupportsHeaderProtection(rel) {
return fmt.Sprintf("Ядро %s старше 5.5 — header protection в нём не работает (upstream issue #210).", rel)
}
return ""
}
var reAwg3KeyLine = regexp.MustCompile(`^\s*(` + strings.Join(AWG3Keys, "|") + `)\s*=`)
// confWithGen31 готовит текст awg1.conf для 3.1: выкидывает старые 3.x-строки (конфиг могли
// править руками), поднимает S1–S4 до допустимых при header protection и дописывает 3.x-блок
// в конец [Interface]. [Peer]-блоки не трогаются — личности пиров переход не меняет.
func confWithGen31(conf, headerKey string) (string, []string) {
var head, rest string
if loc := pPeerSplit.FindStringIndex(conf); loc != nil {
head, rest = conf[:loc[0]], conf[loc[0]:]
} else {
head = conf
}
var lines []string
for _, l := range strings.Split(head, "\n") {
if !reAwg3KeyLine.MatchString(l) {
lines = append(lines, l)
}
}
// Пустые строки и «# имя» в хвосте [Interface] относятся уже к первому [Peer] (их пишет
// rebuildConf) — отделяем, чтобы 3.x-строки встали внутрь [Interface].
var tail []string
for len(lines) > 0 {
t := strings.TrimSpace(lines[len(lines)-1])
if t != "" && !strings.HasPrefix(t, "#") {
break
}
tail = append([]string{lines[len(lines)-1]}, tail...)
lines = lines[:len(lines)-1]
}
if tt := strings.TrimSpace(strings.Join(tail, "\n")); tt != "" {
rest = tt + "\n" + rest
}
iface := strings.Join(lines, "\n")
// S ниже 12 (или вовсе отсутствующий) ядро с header protection не примет. Правка безопасна
// ровно потому, что следом идёт перевыпуск ключей: обе стороны получают новые значения
// одновременно. (Регулярка с \s*, как в Node-версии: захватывает и переводы строк.)
def := DefaultParams()
defaults := map[string]int{"S1": def.S1, "S2": def.S2, "S3": def.S3, "S4": def.S4}
bumped := []string{}
for _, k := range []string{"S1", "S2", "S3", "S4"} {
re := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*(\d+)\s*$`)
m := re.FindStringSubmatch(iface)
cur := -1
if m != nil {
cur, _ = strconv.Atoi(m[1])
}
if m != nil && cur >= minSForHeaderProtection {
continue
}
val := defaults[k]
if minSForHeaderProtection > val {
val = minSForHeaderProtection
}
if m != nil {
// как iface.replace(re, …) без флага g: заменяется только первое совпадение
loc := re.FindStringIndex(iface)
iface = iface[:loc[0]] + fmt.Sprintf("%s = %d", k, val) + iface[loc[1]:]
} else {
iface = trimEndJS(iface) + fmt.Sprintf("\n%s = %d", k, val)
}
was := "нет"
if m != nil {
was = strconv.Itoa(cur)
}
bumped = append(bumped, fmt.Sprintf("%s: %s → %d", k, was, val))
}
a3 := []string{"HeaderProtectionKey = " + headerKey}
for _, kv := range AWG31Defaults {
a3 = append(a3, kv[0]+" = "+kv[1])
}
out := trimEndJS(iface) + "\n" + strings.Join(a3, "\n") + "\n"
if rest != "" {
out += "\n" + rest
}
return out, bumped
}
type upgradeResult struct {
Gen string `json:"gen"`
Backup string `json:"backup"`
BumpedS []string `json:"bumpedS"`
Reissue *reissueResult `json:"reissue"`
}
// upgradeToGen31: единственная настоящая проверка, что ядро приняло параметры, — подъём
// интерфейса, поэтому шаги такие: бэкап конфига → запись 3.1 → restartAwg() → перевыпуск
// ключей. Если ядро отказало, возвращаем прежний конфиг и поднимаем интерфейс обратно:
// операция либо применяется целиком, либо не оставляет следов.
func (s *Server) upgradeToGen31() (*upgradeResult, error) {
before, err := os.ReadFile(s.confPath())
if err != nil {
return nil, err
}
backup := s.confPath() + ".bak-" + jsonStamp(time.Now())
if err := os.WriteFile(backup, before, 0o600); err != nil {
return nil, err
}
hk, err := s.genPSK()
if err != nil {
return nil, err
}
conf, bumped := confWithGen31(string(before), hk)
if err := os.WriteFile(s.confPath(), []byte(conf), 0o600); err != nil {
return nil, err
}
s.log.Info("upgrade: conf rewritten for 3.1", "backup", backup, "bumpedS", bumped)
rerr := s.restartAwg()
if rerr == nil && s.cfg().Gen != "3.1" {
rerr = errors.New("интерфейс поднялся, но конфиг всё ещё 2.0")
}
if rerr != nil {
s.log.Error("upgrade: kernel rejected 3.1, rolling back", "error", rerr)
os.WriteFile(s.confPath(), before, 0o600)
if e2 := s.restartAwg(); e2 != nil {
s.log.Error("upgrade: rollback restart failed", "error", e2)
}
return nil, fmt.Errorf("Ядро не приняло конфиг 3.1 — вернули прежний (копия: %s). "+
"Причина видна в dmesg после `echo \"module amneziawg +p\" > /sys/kernel/debug/dynamic_debug/control`.", backup)
}
// Ключи, выданные на 2.0-параметрах, после смены поколения не подключатся, поэтому
// перевыпускаем сразу — сервер не должен оставаться в состоянии «3.1 поднят, у всех
// нерабочие ключи». Личности пиров сохраняются.
re, err := s.reissueAll()
if err != nil {
return nil, err
}
s.log.Info("upgrade: done", "gen", s.cfg().Gen, "reissued", re.Reissued, "bumpedS", bumped)
return &upgradeResult{Gen: s.cfg().Gen, Backup: backup, BumpedS: bumped, Reissue: re}, nil
}
var _ = jsjson.Undefined
+174
View File
@@ -0,0 +1,174 @@
package ctrl
import (
"os"
"regexp"
"strconv"
"strings"
)
// AWG3Keys — ключи AmneziaWG 3.0/3.1. Порядок — порядок строк в клиентском .conf
// (client/server_scripts/awg/template.conf в amnezia-client). Пустое значение =
// ключ не задан: он не попадает ни в conf, ни в vpn://, и 2.0 остаётся побайтно прежним.
var AWG3Keys = []string{
"HeaderProtectionKey",
"ContentPaddingAddition",
"RekeyAfterTime",
"RekeyTimeout",
"RejectAfterTime",
"KeepaliveTimeout",
"MaxHandshakeAttempts",
"RandomTrailers",
"DisableCookies",
}
// Params — параметры обфускации из [Interface] awg1.conf. Числовые — числа (в
// vpn:// они уходят строками), остальные — строки.
type Params struct {
Jc, Jmin, Jmax int
S1, S2, S3, S4 int
H1, H2, H3, H4 string
I1, I2, I3, I4, I5 string
A3 map[string]string // AWG3Keys → значение ("" = не задано)
}
// DefaultParams — запасные значения на случай, когда awg1.conf не найден.
func DefaultParams() Params {
return Params{
Jc: 6, Jmin: 10, Jmax: 50,
S1: 90, S2: 45, S3: 37, S4: 14,
H1: "1224800044-2116730834",
H2: "2122053282-2133204808",
H3: "2133604274-2140756116",
H4: "2143656228-2147444225",
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
A3: map[string]string{},
}
}
func (p Params) a3(k string) string { return p.A3[k] }
// Gen выводится из параметров, отдельного флага нет: conf — единственный источник
// правды. Header protection — то, что ломает совместимость с 2.0.
func (p Params) Gen() string {
if p.a3("HeaderProtectionKey") != "" {
return "3.1"
}
return "2"
}
var keepaliveByGen = map[string]string{"2": "25", "3.1": "25-35"}
// AWG31Defaults — значения 3.1-параметров для перехода 2.0 → 3.1 (как у install.sh),
// в порядке записи. HeaderProtectionKey генерируется на месте.
var AWG31Defaults = [][2]string{
{"ContentPaddingAddition", "10-100"},
{"RekeyAfterTime", "100-120"},
{"RekeyTimeout", "3-7"},
{"RejectAfterTime", "150-180"},
{"KeepaliveTimeout", "5-15"},
{"MaxHandshakeAttempts", "15-20"},
}
// DefaultMTU — MTU установок до рандомизации; запасное, если в awg1.conf его нет или оно странное.
const DefaultMTU = 1376
// ReadMTU читает MTU из [Interface] awg1.conf: install.sh выбирает его случайно, у каждого
// сервера своё, и клиенты получают его в vpn:// ключе.
func ReadMTU(confFile string) int {
raw, err := os.ReadFile(confFile)
if err != nil {
return DefaultMTU
}
iface := pPeerSplit.Split(string(raw), 2)[0]
m := regexp.MustCompile(`(?m)^\s*MTU\s*=\s*(\d+)`).FindStringSubmatch(iface)
if m == nil {
return DefaultMTU
}
n, _ := strconv.Atoi(m[1])
if n < 1280 || n > 1500 {
return DefaultMTU
}
return n
}
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12.
const minSForHeaderProtection = 12
var (
pPeerSplit = regexp.MustCompile(`(?m)^\[Peer\]`)
numKeys = []string{"Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"}
strKeys = append([]string{"H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"}, AWG3Keys...)
)
// ReadParams читает параметры из [Interface] awg1.conf (всё до первого [Peer]).
//
// Повторяет регулярки Node-версии: `^\s*K\s*=\s*(\d+)` с флагом m. `\s*` в JS
// захватывает и перевод строки, так что строка «I2 = » без значения «съела» бы
// следующую — install.sh таких не пишет, но поведение сохранено намеренно.
func ReadParams(confFile string, warn func(string)) Params {
p := DefaultParams()
raw, err := os.ReadFile(confFile)
if err != nil {
if warn != nil {
warn("awg1.conf не найден — параметры обфускации по умолчанию")
}
return p
}
iface := pPeerSplit.Split(string(raw), 2)[0]
for _, k := range numKeys {
m := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*(\d+)`).FindStringSubmatch(iface)
if m == nil {
continue
}
n, _ := strconv.Atoi(m[1])
switch k {
case "Jc":
p.Jc = n
case "Jmin":
p.Jmin = n
case "Jmax":
p.Jmax = n
case "S1":
p.S1 = n
case "S2":
p.S2 = n
case "S3":
p.S3 = n
case "S4":
p.S4 = n
}
}
for _, k := range strKeys {
// В JS `.` не совпадает с \n, \r, U+2028, U+2029; в Go-режиме (?m) без (?s) — только с \n.
m := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*([^\n\r\x{2028}\x{2029}]*)$`).FindStringSubmatch(iface)
if m == nil {
continue
}
v := strings.TrimSpace(m[1])
switch k {
case "H1":
p.H1 = v
case "H2":
p.H2 = v
case "H3":
p.H3 = v
case "H4":
p.H4 = v
case "I1":
p.I1 = v
case "I2":
p.I2 = v
case "I3":
p.I3 = v
case "I4":
p.I4 = v
case "I5":
p.I5 = v
default:
p.A3[k] = v
}
}
return p
}
+424
View File
@@ -0,0 +1,424 @@
package ctrl
import (
"math"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func nowFn() time.Time { return time.Now() }
type ob = jsjson.Object
type kv = jsjson.KV
func ok(v any) (result, error) { return result{200, v}, nil }
func created(v any) (result, error) { return result{201, v}, nil }
func (s *Server) routes() []route {
a := []func(*rctx) error{s.auth}
an := []func(*rctx) error{s.auth, validateName}
R := func(method, path string, lock bool, mw []func(*rctx) error, h func(*rctx) (result, error)) route {
segs := strings.Split(strings.Trim(path, "/"), "/")
return route{method: method, segs: segs, mw: mw, lock: lock, h: h}
}
return []route{
R("GET", "/health", false, nil, func(c *rctx) (result, error) {
st := s.interfaceStatus()
cfg := s.cfg()
status, word := 200, "ok"
awgSt := "ok"
if !st.Up {
status, word, awgSt = 503, "degraded", "down"
}
return result{status, ob{{"status", word}, {"server", cfg.ServerName}, {"ip", cfg.ServerIP}, {"gen", cfg.Gen},
{"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil
}),
R("GET", "/api/connlog", false, a, func(c *rctx) (result, error) {
v, err := s.connlog.list(queryInt(c, "limit"), queryInt(c, "before"), c.r.URL.Query().Get("q"))
if err != nil {
return result{}, err
}
return ok(v)
}),
R("POST", "/api/users", true, an, func(c *rctx) (result, error) {
name, _ := c.obj().Get2("name").(string)
if u, err := s.userGet(name); err != nil {
return result{}, err
} else if u != nil {
return result{}, httpErr(409, "Пользователь уже существует")
}
u, err := s.addUser(name)
if err != nil {
return result{}, err
}
return created(u)
}),
R("GET", "/api/users", true, a, func(c *rctx) (result, error) {
rows, err := s.db.Query("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''")
if err != nil {
return result{}, err
}
stats := s.getPeersData()
list := []any{}
for rows.Next() {
var n, ip, pub, vk, kg string
if err := rows.Scan(&n, &ip, &pub, &vk, &kg); err != nil {
rows.Close()
return result{}, err
}
ps := stats[pub]
list = append(list, ob{{"name", n}, {"ip", ip}, {"pub_key", pub}, {"vpn_key", vk}, {"key_gen", kg},
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}})
}
rows.Close()
return ok(ob{{"users", list}})
}),
R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) {
torrent := s.torrent.SeenSet()
rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''")
if err != nil {
return result{}, err
}
stats := s.getPeersData()
list := []any{}
for rows.Next() {
var n, ip, pub, kg string
if err := rows.Scan(&n, &ip, &pub, &kg); err != nil {
rows.Close()
return result{}, err
}
ps := stats[pub]
list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online},
{"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)},
{"torrent", torrent[ip]}})
}
rows.Close()
return ok(ob{{"users", list}})
}),
// ⚠️ До «/api/users/:name», иначе тот примет «reissue» за имя пользователя.
R("POST", "/api/users/reissue", true, a, func(c *rctx) (result, error) {
r, err := s.reissueAll()
if err != nil {
return result{}, err
}
return ok(r)
}),
R("POST", "/api/users/:name", true, an, func(c *rctx) (result, error) {
u, err := s.userGet(c.params["name"])
if err != nil {
return result{}, err
}
if u == nil {
return result{}, httpErr(404, "Пользователь не найден")
}
return ok(u)
}),
R("DELETE", "/api/users/:name", true, an, func(c *rctx) (result, error) {
if err := s.removeUser(c.params["name"]); err != nil {
return result{}, err
}
return ok(ob{{"success", true}, {"name", c.params["name"]}})
}),
R("POST", "/api/peers", true, a, func(c *rctx) (result, error) {
owner, isStr := c.obj().Get2("owner").(string)
if !isStr || !reOwner.MatchString(owner) {
return result{}, httpErr(400, "Неверный owner")
}
pub, err := checkPubKeyAny(c.obj().Get2("pub_key"))
if err != nil {
return result{}, err
}
r, err := s.addPeer(pub, owner)
if err != nil {
return result{}, err
}
return created(r)
}),
R("GET", "/api/peers", true, a, func(c *rctx) (result, error) {
torrent := s.torrent.SeenSet()
stats := s.getPeersData()
peers, err := s.peersAll()
if err != nil {
return result{}, err
}
list := []any{}
for _, p := range peers {
ps := stats[p.PubKey]
list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner},
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx},
{"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", torrent[p.IP]}})
}
return ok(ob{{"peers", list}})
}),
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять
// их после сбоя `awg set … remove` некому. Только чтение — панель сама решает, что делать.
R("GET", "/api/audit", true, a, func(c *rctx) (result, error) {
r := runCmd("awg", "show", s.iface, "dump")
if !r.ok() {
return result{}, httpErr(503, "awg show не ответил")
}
known, err := s.knownPubKeys()
if err != nil {
return result{}, err
}
now := float64(time.Now().Unix())
ghosts := []any{}
lines := strings.Split(r.Stdout, "\n")
for _, l := range lines[1:] {
f := strings.Split(l, "\t")
if f[0] == "" || known[f[0]] {
continue
}
num := func(i int) float64 {
if i < len(f) {
return orZero(jsNumber(f[i]))
}
return 0
}
hs := num(4)
ghosts = append(ghosts, ob{{"pub_key", f[0]}, {"lastHandshake", hs}, {"online", hs > 0 && now-hs < 180}, {"rx", num(5)}, {"tx", num(6)}})
}
return ok(ob{{"ghosts", ghosts}})
}),
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется:
// пир, который успел появиться в users/peers, не трогаем. Строк в базе у «призраков»
// нет — снимается только ядро.
R("POST", "/api/audit/clean", true, a, func(c *rctx) (result, error) {
arr, isArr := c.obj().Get2("pub_keys").([]any)
if !isArr || len(arr) > syncMax {
return result{}, httpErr(400, "Неверный список ключей")
}
keys, err := checkPubKeys(arr)
if err != nil {
return result{}, err
}
removed, skipped := []string{}, []string{}
for _, k := range keys {
known, err := s.isKnownPub(k)
if err != nil {
return result{}, err
}
skip := known
if !skip {
sp, err := s.getServerPublicKey()
if err != nil {
return result{}, err
}
kp := s.kernelPeers()
skip = k == sp || kp == nil || !kp[k]
}
if skip {
skipped = append(skipped, k)
} else if s.dropFromKernel(k) {
removed = append(removed, k)
} else {
skipped = append(skipped, k)
}
}
if len(removed) > 0 {
s.log.Warn("ghost peers removed from kernel", "count", len(removed), "removed", removed)
}
return ok(ob{{"removed", removed}, {"skipped", skipped}})
}),
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
R("PUT", "/api/peers/sync", true, a, func(c *rctx) (result, error) {
owner, isStr := c.obj().Get2("owner").(string)
if !isStr || !rePanelID.MatchString(owner) {
return result{}, httpErr(400, "Неверный owner")
}
arr, isArr := c.obj().Get2("keep").([]any)
if !isArr || len(arr) > syncMax {
return result{}, httpErr(400, "Неверный список ключей")
}
keep, err := checkPubKeys(arr)
if err != nil {
return result{}, err
}
r, err := s.syncOwnedPeers(owner, keep)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("PUT", "/api/peers/:pub", true, a, func(c *rctx) (result, error) {
oldPub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
newPub, err := checkPubKeyAny(c.obj().Get2("pub_key"))
if err != nil {
return result{}, err
}
r, err := s.replacePeer(oldPub, newPub)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("POST", "/api/peers/:pub/psk", true, a, func(c *rctx) (result, error) {
pub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
r, err := s.rotatePeerPSK(pub)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("DELETE", "/api/peers/:pub", true, a, func(c *rctx) (result, error) {
pub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
if err := s.removePeer(pub); err != nil {
return result{}, err
}
return ok(ob{{"success", true}})
}),
// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf;
// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf.
R("GET", "/api/profile", true, a, func(c *rctx) (result, error) {
sp, err := s.getServerPublicKey()
if err != nil {
return result{}, err
}
cfg := s.cfg()
return ok(ob{{"name", cfg.ServerName}, {"endpoint", cfg.ServerIP + ":" + strconv.Itoa(cfg.ServerPort)},
{"server_pub", sp}, {"gen", cfg.Gen}, {"dns", []string{cfg.DNS1, cfg.DNS2}},
{"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}})
}),
// Маскировка первого пакета (I1): посмотреть и сменить. Путь под /api/ — его пропускает
// awg-agent нод. Интерфейс не трогается: I1 нужен только клиентам (см. i1.go).
R("GET", "/api/masking", false, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.cfg().Params.I1)) }),
R("POST", "/api/masking", true, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.regenI1())) }),
R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) {
if err := s.restartAwg(); err != nil {
return result{}, err
}
return ok(ob{{"success", true}})
}),
// Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей. Ошибку отдаём
// текстом: панель показывает её оператору, а причины отказа (старый модуль, старое ядро,
// отказ ядра на подъёме) требуют разных действий.
R("POST", "/awg/upgrade", true, a, func(c *rctx) (result, error) {
if s.cfg().Gen == "3.1" {
return result{}, httpErr(409, "Сервер уже работает на AmneziaWG 3.1")
}
if blocker := s.checkGen31Support(); blocker != "" {
return result{}, httpErr(409, blocker)
}
r, err := s.upgradeToGen31()
if err != nil {
return result{}, httpErr(500, err.Error())
}
return ok(r)
}),
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не
// пропустит (и без PATCH: агент отвечает на него 400).
R("GET", "/api/shaping", false, a, func(c *rctx) (result, error) { return ok(s.shaping.Status()) }),
R("PUT", "/api/shaping", false, a, func(c *rctx) (result, error) {
b := c.obj()
enabled, e1 := b.Get2("enabled").(bool)
auto, e2 := b.Get2("auto").(bool)
if !e1 || !e2 {
return result{}, httpErr(400, "enabled и auto должны быть true/false")
}
mbit := func(v any, what string) (int, error) {
n := jsToNumber(v)
if math.IsNaN(n) || n != math.Trunc(n) || n < 0 || n > 100_000 {
return 0, httpErr(400, what+": целое число Мбит/с от 1 до 100000")
}
return int(n), nil
}
down, err := mbit(b.Get2("downMbit"), "Загрузка")
if err != nil {
return result{}, err
}
up, err := mbit(b.Get2("upMbit"), "Отдача")
if err != nil {
return result{}, err
}
if enabled && (down < 1 || up < 1) {
return result{}, httpErr(400, "Для включения укажите ширину канала в обе стороны")
}
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
if err := s.shaping.Update(ShapingSettings{Enabled: enabled, DownMbit: down, UpMbit: up, Auto: auto}); err != nil {
return result{}, httpErr(500, err.Error())
}
return ok(s.shaping.Status())
}),
// Блокировка BitTorrent. Тоже под /api/ и через PUT — чтобы проходила через awg-agent на ноде.
R("GET", "/api/torrent", false, a, func(c *rctx) (result, error) { return ok(s.torrent.Status()) }),
R("PUT", "/api/torrent", false, a, func(c *rctx) (result, error) {
enabled, isBool := c.obj().Get2("enabled").(bool)
if !isBool {
return result{}, httpErr(400, "enabled должен быть true/false")
}
// nft не нашёлся или ядро отказало: оператору нужна причина, а не 500.
if err := s.torrent.Update(TorrentSettings{Enabled: enabled}); err != nil {
return result{}, httpErr(500, err.Error())
}
return ok(s.torrent.Status())
}),
R("GET", "/api/metrics", false, a, func(c *rctx) (result, error) {
d, err := s.metricsCached()
if err != nil {
return result{}, err
}
return ok(d)
}),
R("POST", "/api/monitor/install", false, a, func(c *rctx) (result, error) {
if err := s.startMonitorJob("install"); err != nil {
return result{}, err
}
return result{202, ob{{"running", true}, {"action", "install"}}}, nil
}),
R("POST", "/api/monitor/remove", false, a, func(c *rctx) (result, error) {
if err := s.startMonitorJob("remove"); err != nil {
return result{}, err
}
return result{202, ob{{"running", true}, {"action", "remove"}}}, nil
}),
R("GET", "/api/monitor/job", false, a, func(c *rctx) (result, error) { return ok(s.monitorView()) }),
R("GET", "/awg/status", true, a, func(c *rctx) (result, error) { return ok(s.interfaceStatus()) }),
R("POST", "/awg/start", true, a, func(c *rctx) (result, error) {
st, err := s.startInterface()
if err != nil {
return result{}, err
}
return ok(st)
}),
}
}
+193
View File
@@ -0,0 +1,193 @@
// Package ctrl — awg-ctrl: привилегированный REST API, который управляет интерфейсом
// AmneziaWG (`awg`, `awg-quick`, `ip`, `tc`, `nft`) и базой пользователей users.db.
// Слушает только 127.0.0.1; снаружи до него добирается лишь awg-ui (и агент ноды, и CLI)
// с подписанным Ed25519-токеном.
package ctrl
import (
"crypto/ed25519"
"database/sql"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"sync"
"time"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/logx"
)
// Config — то, что в Node-версии лежало в CONFIG. Параметры и поколение меняются при
// restartAwg/переходе на 3.1 — читать через Server.cfg().
type Config struct {
ServerIP string
ServerPort int
ServerName string
DNS1, DNS2 string
MTU int
Keepalive string
Subnet string
Params Params
Gen string
AwgModule string
AwgTools string
}
type Server struct {
iface string
confDir string
dbPath string
serverPrivKeyFile string
port int
db *sql.DB
log *logx.Logger
pub ed25519.PublicKey
// mu делает последовательности «БД + awg + awg1.conf» неделимыми. В Node весь код между
// await атомарен (один поток); в Go без этого два запроса перемешали бы шаги.
mu sync.Mutex
cfgMu sync.RWMutex
c Config
shaping *Shaping
torrent *Torrent
connlog *ConnLog
metrics metricsCache
monitor monitorJob
cliPath string
cliLog string
}
func (s *Server) cfg() Config {
s.cfgMu.RLock()
defer s.cfgMu.RUnlock()
return s.c
}
func (s *Server) keyEnv() keyEnv {
c := s.cfg()
return keyEnv{P: c.Params, ServerIP: c.ServerIP, ServerPort: c.ServerPort, ServerName: c.ServerName,
DNS1: c.DNS1, DNS2: c.DNS2, MTU: c.MTU, Keepalive: c.Keepalive, Gen: c.Gen, Subnet: c.Subnet}
}
func envOr(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
func getLocalIP() string {
r := runCmd("ip", "route", "show", "default")
m := regexp.MustCompile(`dev\s+(\S+)`).FindStringSubmatch(r.Stdout)
if m == nil {
return ""
}
a := runCmd("ip", "addr", "show", m[1])
if m := regexp.MustCompile(`inet\s+([\d.]+)`).FindStringSubmatch(a.Stdout); m != nil {
return m[1]
}
return ""
}
// New открывает базу и ключи, читает параметры из awg1.conf. Не слушает порт.
func New() (*Server, error) {
s := &Server{
iface: "awg1",
confDir: envOr("AWG_CONF_DIR", "/etc/amnezia/amneziawg"),
serverPrivKeyFile: envOr("AWG_SERVER_PRIV_KEY_FILE", "/etc/amnezia/server_private.key"),
cliPath: envOr("AWG_CLI", "/usr/local/bin/awg-ctrl"),
}
s.dbPath = envOr("AWG_DB_PATH", filepath.Join(s.confDir, "users.db"))
port, _ := strconv.Atoi(os.Getenv("PORT"))
if port == 0 {
port = 3005
}
s.port = port
cwd, _ := os.Getwd()
s.log = logx.New(os.Stdout, filepath.Join(cwd, "logs"))
s.cliLog = envOr("MONITOR_LOG", filepath.Join(filepath.Dir(cwd), "logs", "monitor-job.log"))
pubFile := envOr("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")
pub, err := iauth.LoadPublic(pubFile)
if err != nil {
s.log.Error("FATAL: публичный ключ внутренней авторизации не найден: " + pubFile)
return nil, fmt.Errorf("FATAL: публичный ключ внутренней авторизации не найден: %s", pubFile)
}
s.pub = pub
db, err := openDB(s.dbPath)
if err != nil {
return nil, err
}
s.db = db
// Адрес/порт/имя: БД, затем env, затем (для адреса) маршрут по умолчанию. Как в Node,
// запасной адрес вычисляется всегда, даже если в БД уже есть значение.
fallbackIP := ""
if v, ok := os.LookupEnv("SERVER_IP"); ok {
fallbackIP = v
} else {
fallbackIP = getLocalIP()
}
ip := s.getCfg("serverIp", fallbackIP)
portStr := s.getCfg("serverPort", envOr("SERVER_PORT", "51820"))
name := s.getCfg("serverName", envOr("SERVER_NAME", "VPN"))
if ip == "" {
return nil, fmt.Errorf("SERVER_IP не задан — передай через env при первом запуске")
}
s.setCfg("serverIp", ip)
s.setCfg("serverPort", portStr)
s.setCfg("serverName", name)
sp, _ := strconv.Atoi(portStr)
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
params.I1 = s.ensureI1() // I1 — из БД, не из conf (см. i1.go)
gen := params.Gen()
module, tools := readVersions()
s.c = Config{
ServerIP: ip, ServerPort: sp, ServerName: name,
DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: ReadMTU(s.confPath()), Subnet: "10.9",
Keepalive: keepaliveByGen[gen], Params: params, Gen: gen,
AwgModule: module, AwgTools: tools,
}
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
s.shaping = newShaping(s)
s.torrent = newTorrent(s)
s.connlog = newConnLog(s)
return s, nil
}
// Run слушает 127.0.0.1:PORT и выполняет стартовую последовательность Node-версии:
// listen → ensureInterfaceUp → syncPeers → шейпинг. Нет интерфейса — завершаемся с ошибкой:
// health-gate CLI опирается на это (без поднятого awg1 awg-ctrl не нужен).
func (s *Server) Run() error {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
if err != nil {
return err
}
srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 15 * time.Second}
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
c := s.cfg()
s.log.Info("Server started", "port", s.port, "host", "127.0.0.1", "serverName", c.ServerName, "serverIp", c.ServerIP)
if err := s.ensureInterfaceUp(); err != nil {
return err
}
if err := s.syncPeers(); err != nil {
return err
}
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
go s.shaping.Apply()
s.shaping.Start()
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
s.connlog.Start()
return <-errc
}
+634
View File
@@ -0,0 +1,634 @@
package ctrl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
//
// Схема:
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
// с меткой «торренты» — туда же, пока метка висит (см. torrent.go), независимо от перегрузки
//
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому Apply зовётся на
// старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
//
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по времени,
// ни по неактивности (правило проекта: удаление только по явному списку от панели).
type ShapingSettings struct {
Enabled bool `json:"enabled"`
DownMbit int `json:"downMbit"`
UpMbit int `json:"upMbit"`
Auto bool `json:"auto"`
}
type QdiscStats struct {
Packets float64 `json:"packets"`
Bytes float64 `json:"bytes"`
Drops float64 `json:"drops"`
Backlog float64 `json:"backlog"`
}
type ShapingStatus struct {
Settings ShapingSettings `json:"settings"`
Active bool `json:"active"`
Congested bool `json:"congested"`
Heavy []string `json:"heavy"`
Devices int `json:"devices"`
RateDown float64 `json:"rateDown"`
RateUp float64 `json:"rateUp"`
Down *QdiscStats `json:"down,omitempty"`
Up *QdiscStats `json:"up,omitempty"`
}
const (
congestOn = 0.85 // доля ширины, выше которой канал считается перегруженным
congestOff = 0.70 // и ниже которой перегрузка снимается (гистерезис)
activeMbit = 0.1 // меньше — устройство не считается активным
heavyMult = 1.5 // тяжёлое: ≥ 1.5 честных долей…
heavyMinFrac = 0.05 // …и не меньше 5% ширины канала
checkEvery = 3 // страховочная проверка qdisc — раз в N тиков
ifbName = "ifb-awg"
nftTable = "awg_shape"
)
// Окна и пороги; переопределение через env — только для тестов на стенде.
func envNum(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
return f
}
}
return def
}
type tick struct{ dt, bytes float64 }
type peerCounters struct {
IP string
Rx, Tx float64
}
// orderedSet — множество с порядком вставки (JS Set): порядок попадает в nft-скрипт.
type orderedSet struct {
keys []string
m map[string]bool
}
func newOrderedSet() *orderedSet { return &orderedSet{m: map[string]bool{}} }
func (o *orderedSet) has(k string) bool { return o.m[k] }
func (o *orderedSet) add(k string) {
if !o.m[k] {
o.m[k] = true
o.keys = append(o.keys, k)
}
}
func (o *orderedSet) del(k string) {
if !o.m[k] {
return
}
delete(o.m, k)
for i, x := range o.keys {
if x == k {
o.keys = append(o.keys[:i], o.keys[i+1:]...)
break
}
}
}
func (o *orderedSet) clear() { o.keys, o.m = nil, map[string]bool{} }
func (o *orderedSet) list() []string { return append([]string(nil), o.keys...) }
func (o *orderedSet) size() int { return len(o.keys) }
type Shaping struct {
s *Server
opMu sync.Mutex // сериализует tc/nft: два параллельных Apply дали бы полусобранные qdisc
mu sync.Mutex // состояние сэмплера
prev map[string][2]float64
history map[string][]tick
histOrder []string
totals []tick
heavy *orderedSet
applied *orderedSet // что сейчас стоит в nft-наборе: тяжёлые ∪ с меткой торрента
congested bool
lastRateDn float64
lastRateUp float64
lastTickAt time.Time
tickNo int
lastError string
windowTicks int
sampleS float64
started bool
}
func newShaping(s *Server) *Shaping {
sampleS := envNum("SHAPE_SAMPLE_S", 10)
windowS := envNum("SHAPE_WINDOW_S", 600)
return &Shaping{
s: s, prev: map[string][2]float64{}, history: map[string][]tick{}, heavy: newOrderedSet(), applied: newOrderedSet(),
sampleS: sampleS, windowTicks: int(math.Max(3, math.Round(windowS/sampleS))),
}
}
func (h *Shaping) Settings() ShapingSettings {
s := h.s
down, _ := strconv.Atoi(s.getCfg("shapeDownMbit", "0"))
up, _ := strconv.Atoi(s.getCfg("shapeUpMbit", "0"))
return ShapingSettings{
Enabled: s.getCfg("shapeEnabled", "0") == "1",
DownMbit: down, UpMbit: up,
Auto: s.getCfg("shapeAuto", "1") == "1",
}
}
func (h *Shaping) save(st ShapingSettings) {
b := func(v bool) string {
if v {
return "1"
}
return "0"
}
h.s.setCfg("shapeEnabled", b(st.Enabled))
h.s.setCfg("shapeDownMbit", strconv.Itoa(st.DownMbit))
h.s.setCfg("shapeUpMbit", strconv.Itoa(st.UpMbit))
h.s.setCfg("shapeAuto", b(st.Auto))
}
func (h *Shaping) noteError(err error) {
h.mu.Lock()
defer h.mu.Unlock()
if m := err.Error(); m != h.lastError { // одинаковую ошибку в лог не спамим
h.s.log.Warn("shaping: " + m)
h.lastError = m
}
}
func (h *Shaping) overhead() int {
if v := os.Getenv("SHAPE_OVERHEAD"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 {
return int(f)
}
}
return 60 + h.s.cfg().Params.S4
}
func shExec(name string, args ...string) (string, error) {
return runCtx(15*time.Second, name, args...)
}
func shExecStdin(name string, args []string, input string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var se bytes.Buffer
cmd.Stderr = &se
cmd.Stdin = strings.NewReader(input)
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(se.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("%s: %s", name, msg)
}
return nil
}
func (h *Shaping) nftApply(heavy []string) error {
elements := ""
if len(heavy) > 0 {
elements = "\n elements = { " + strings.Join(heavy, ", ") + " }"
}
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
script := "\nadd table inet " + nftTable + "\ndelete table inet " + nftTable + "\ntable inet " + nftTable + " {\n" +
" set heavy {\n type ipv4_addr" + elements + "\n }\n" +
" chain postrouting {\n type filter hook postrouting priority -150; policy accept;\n" +
" oifname \"" + h.s.iface + "\" ip daddr @heavy ip dscp set cs1\n }\n}\n"
return shExecStdin("nft", []string{"-f", "-"}, script)
}
func (h *Shaping) hasCake(dev string) bool {
out, err := shExec("tc", "qdisc", "show", "dev", dev, "root")
return err == nil && strings.Contains(out, "cake")
}
func (h *Shaping) hasIngressRedirect() bool {
out, err := shExec("tc", "filter", "show", "dev", h.s.iface, "parent", "ffff:")
return err == nil && strings.Contains(out, "mirred")
}
func (h *Shaping) applyNow() error {
st := h.Settings()
if !st.Enabled || st.DownMbit <= 0 || st.UpMbit <= 0 {
h.removeNow()
return nil
}
iface := h.s.iface
oh := strconv.Itoa(h.overhead())
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
if _, err := shExec("tc", "qdisc", "replace", "dev", iface, "root", "cake",
"bandwidth", fmt.Sprintf("%dmbit", st.DownMbit), "diffserv3", "dual-dsthost", "overhead", oh); err != nil {
return err
}
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
if _, err := shExec("ip", "link", "show", ifbName); err != nil {
if _, err := shExec("ip", "link", "add", ifbName, "type", "ifb"); err != nil {
return err
}
}
if _, err := shExec("ip", "link", "set", ifbName, "up"); err != nil {
return err
}
if _, err := shExec("tc", "qdisc", "replace", "dev", ifbName, "root", "cake",
"bandwidth", fmt.Sprintf("%dmbit", st.UpMbit), "besteffort", "dual-srchost", "overhead", oh); err != nil {
return err
}
if !h.hasIngressRedirect() {
shExec("tc", "qdisc", "del", "dev", iface, "ingress") // ошибки игнорируем
if _, err := shExec("tc", "qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"); err != nil {
return err
}
if _, err := shExec("tc", "filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
"matchall", "action", "mirred", "egress", "redirect", "dev", ifbName); err != nil {
return err
}
}
heavy := h.desired()
if err := h.nftApply(heavy); err != nil {
return err
}
h.mu.Lock()
h.applied = setOf(heavy)
h.lastError = ""
h.mu.Unlock()
h.s.log.Info("shaping applied", "down", st.DownMbit, "up", st.UpMbit, "overhead", oh, "heavy", len(heavy))
return nil
}
func (h *Shaping) removeNow() {
iface := h.s.iface
shExec("tc", "qdisc", "del", "dev", iface, "root")
shExec("tc", "qdisc", "del", "dev", iface, "ingress")
shExec("ip", "link", "del", ifbName)
shExec("nft", "delete", "table", "inet", nftTable)
h.mu.Lock()
h.heavy.clear()
h.applied.clear()
h.history, h.histOrder, h.prev = map[string][]tick{}, nil, map[string][2]float64{}
h.totals = nil
h.congested = false
h.mu.Unlock()
}
// Apply не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от
// ensureInterfaceUp).
func (h *Shaping) Apply() {
h.opMu.Lock()
err := h.applyNow()
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
// Update сохраняет настройки и применяет их; ошибку применения отдаёт вызывающему —
// пользователю нужна честная причина, а не молчаливый warn.
func (h *Shaping) Update(st ShapingSettings) error {
h.save(st)
h.opMu.Lock()
err := h.applyNow()
h.opMu.Unlock()
if err == nil {
h.mu.Lock()
h.lastError = ""
h.mu.Unlock()
}
return err
}
// desired — кого держать в фоновом классе: тяжёлые (порядок сэмплера) и, следом, устройства с
// меткой «торренты». Метку ставит и снимает nft (таймаут), поэтому сверяемся каждый тик.
func (h *Shaping) desired() []string {
h.mu.Lock()
out := setOf(h.heavy.list())
h.mu.Unlock()
for _, ip := range h.s.torrent.SeenList() {
out.add(ip)
}
return out.list()
}
func setOf(list []string) *orderedSet {
o := newOrderedSet()
for _, k := range list {
o.add(k)
}
return o
}
// syncNft — привести nft-набор к desired(); без изменений nft не трогаем.
func (h *Shaping) syncNft() {
want := h.desired()
h.mu.Lock()
same := len(want) == h.applied.size()
for _, ip := range want {
same = same && h.applied.has(ip)
}
h.mu.Unlock()
if same {
return
}
h.opMu.Lock()
err := h.nftApply(want)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
return
}
h.mu.Lock()
h.applied = setOf(want)
h.mu.Unlock()
}
// ───────── сэмплер (RFC 6057) ─────────
func (h *Shaping) pushTick(arr []tick, t tick) []tick {
arr = append(arr, t)
for len(arr) > h.windowTicks {
arr = arr[1:]
}
return arr
}
func mbit(arr []tick) float64 {
var dt, by float64
for _, t := range arr {
dt += t.dt
by += t.bytes
}
if dt <= 0 {
return 0
}
return by * 8 / dt / 1e6
}
func (h *Shaping) readPeers() ([]peerCounters, error) {
out, err := runCtx(10*time.Second, "awg", "show", h.s.iface, "dump")
if err != nil {
return nil, err
}
var rows []peerCounters
lines := strings.Split(out, "\n")
if len(lines) > 0 {
lines = lines[1:]
}
for _, l := range lines {
f := strings.Split(l, "\t")
if len(f) < 4 || f[0] == "" || f[3] == "" {
continue
}
ip := strings.SplitN(strings.SplitN(f[3], ",", 2)[0], "/", 2)[0]
rx, tx := 0.0, 0.0
if len(f) > 5 {
rx = orZero(jsNumber(f[5]))
}
if len(f) > 6 {
tx = orZero(jsNumber(f[6]))
}
rows = append(rows, peerCounters{ip, rx, tx})
}
return rows, nil
}
func orZero(f float64) float64 {
if math.IsNaN(f) {
return 0
}
return f
}
func (h *Shaping) tick() {
st := h.Settings()
if !st.Enabled {
return
}
now := time.Now()
h.mu.Lock()
dt := h.sampleS
if !h.lastTickAt.IsZero() {
dt = now.Sub(h.lastTickAt).Seconds()
}
h.lastTickAt = now
h.tickNo++
tickNo := h.tickNo
h.mu.Unlock()
// В конце любого тика, в том числе пока окно не набралось: метка торрента не ждёт 10 минут.
defer h.syncNft()
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
if tickNo%checkEvery == 0 && !h.hasCake(h.s.iface) {
h.s.log.Warn("shaping: qdisc на интерфейсе пропал, применяю заново")
h.Apply()
}
peers, err := h.readPeers()
if err != nil {
h.noteError(err)
return
}
h.mu.Lock()
var totalDown, totalUp float64
seen := map[string]bool{}
for _, p := range peers {
seen[p.IP] = true
before, had := h.prev[p.IP]
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира →
// отрицательная дельта = 0.
dTx, dRx := 0.0, 0.0
if had && p.Tx >= before[1] {
dTx = p.Tx - before[1]
}
if had && p.Rx >= before[0] {
dRx = p.Rx - before[0]
}
h.prev[p.IP] = [2]float64{p.Rx, p.Tx}
totalDown += dTx
totalUp += dRx
if _, ok := h.history[p.IP]; !ok {
h.histOrder = append(h.histOrder, p.IP)
}
h.history[p.IP] = h.pushTick(h.history[p.IP], tick{dt, dTx})
}
var kept []string
for _, ip := range h.histOrder {
if seen[ip] {
kept = append(kept, ip)
continue
}
delete(h.prev, ip)
delete(h.history, ip)
h.heavy.del(ip)
}
h.histOrder = kept
h.totals = h.pushTick(h.totals, tick{dt, totalDown})
h.lastRateDn = totalDown * 8 / dt / 1e6
h.lastRateUp = totalUp * 8 / dt / 1e6
if !st.Auto {
h.heavy.clear()
h.congested = false
h.mu.Unlock()
return
}
if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем
h.mu.Unlock()
return
}
avg := mbit(h.totals)
wasCongested := h.congested
if !h.congested && avg >= congestOn*float64(st.DownMbit) {
h.congested = true
}
if h.congested && avg < congestOff*float64(st.DownMbit) {
h.congested = false
}
next := newOrderedSet()
if h.congested {
type rate struct {
ip string
r float64
}
var rates []rate
for _, ip := range h.histOrder {
if hh := h.history[ip]; len(hh) >= h.windowTicks {
rates = append(rates, rate{ip, mbit(hh)})
}
}
active := 0
for _, r := range rates {
if r.r >= activeMbit {
active++
}
}
share := float64(st.DownMbit) / math.Max(1, float64(active))
for _, r := range rates {
wasHeavy := h.heavy.has(r.ip)
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
if r.r >= math.Max(heavyMult*share, heavyMinFrac*float64(st.DownMbit)) || (wasHeavy && r.r >= share) {
next.add(r.ip)
}
}
}
changed := next.size() != h.heavy.size()
if !changed {
for _, ip := range next.keys {
if !h.heavy.has(ip) {
changed = true
break
}
}
}
if changed || wasCongested != h.congested {
h.heavy = next
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", next.list())
}
h.mu.Unlock()
}
func (h *Shaping) Start() {
h.mu.Lock()
if h.started {
h.mu.Unlock()
return
}
h.started = true
h.mu.Unlock()
go func() {
t := time.NewTicker(time.Duration(h.sampleS * float64(time.Second)))
defer t.Stop()
var busy sync.Mutex
for range t.C {
if !busy.TryLock() { // предыдущий тик ещё идёт — пропускаем, а не копим очередь
continue
}
go func() {
defer busy.Unlock()
h.tick()
}()
}
}()
}
// ───────── состояние ─────────
func (h *Shaping) qdiscStats(dev string) *QdiscStats {
out, err := shExec("tc", "-s", "-j", "qdisc", "show", "dev", dev, "root")
if err != nil {
return nil
}
var arr []map[string]any
if json.Unmarshal([]byte(out), &arr) != nil {
return nil
}
for _, q := range arr {
if q["kind"] == "cake" {
n := func(k string) float64 { f, _ := q[k].(float64); return f }
return &QdiscStats{n("packets"), n("bytes"), n("drops"), n("backlog")}
}
}
return nil
}
func (h *Shaping) PriorityOf(ip string) string {
h.mu.Lock()
defer h.mu.Unlock()
if h.applied.has(ip) { // то, что реально стоит в nft: тяжёлые и с меткой торрента
return "background"
}
return "normal"
}
func (h *Shaping) Status() ShapingStatus {
st := h.Settings()
var active bool
var down, up *QdiscStats
if st.Enabled {
active = h.hasCake(h.s.iface)
down, up = h.qdiscStats(h.s.iface), h.qdiscStats(ifbName)
}
h.mu.Lock()
defer h.mu.Unlock()
devices := 0
for _, hh := range h.history {
if mbit(hh) >= activeMbit {
devices++
}
}
heavy := h.applied.list()
if heavy == nil {
heavy = []string{}
}
round1 := func(f float64) float64 { return math.Round(f*10) / 10 }
return ShapingStatus{
Settings: st, Active: active, Congested: h.congested, Heavy: heavy, Devices: devices,
RateDown: round1(h.lastRateDn), RateUp: round1(h.lastRateUp), Down: down, Up: up,
}
}

Some files were not shown because too many files have changed in this diff Show More