diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ecae9e..bc728949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,76 @@ User-facing Xrayebator changes. The server manager and Electron application are published from the canonical `howdeploy/Xrayebator` repository. +## [0.6.5] - UNRELEASED (beta) + +Multi-protocol stage: Xray Reality is joined by Hysteria 2 (UDP/QUIC) and AmneziaWG 3.1 +(system VPN), a neutral backend registry with per-profile grants and lifecycle, a +HAPP client-routing generator with split routing, softer SNI defaults, a graceful +`http_tls` degradation path for hosters that filter port 80, and a wave of GUI feedback +polish. **0.6.5 is in testing — if you need a proven build, stay on 0.6.0.** + +### Added + +- **Hysteria 2 backend** (multi-protocol slices 2–3): install/uninstall/status via the + registry, a dedicated `hysteria` system user with its own `hysteria-server.service` + (CAP_NET_BIND_SERVICE), QUIC buffer sysctls, per-profile grants (`hysteria2-grant`) + with passwords stored in the profile JSON, server config regenerated from profiles + with rollback, adaptive TLS (Let's Encrypt cert copied with a renewal deploy-hook, + or self-signed with `insecure=1` client links), and `hysteria2://` lines appended to + both subscription bodies behind a kill switch. +- **AmneziaWG 3.1 backend** (slices 4–5 + 3.1): kernel module via DKMS (amnezia PPA + with a manual source-build fallback), `awg0` interface with junk params matching the + official Amnezia dialect, per-profile peers with keypair + preshared key, `awg0.conf` + regenerated from profiles, client `.conf` and `awg-conf` JSON for the GUI, AWG 3.1 + format on by default (`HeaderProtectionKey`, `RandomTrailers`, `S1`–`S4`) with an + `awg-31` toggle and explicit re-download warnings, and the GUI delivering keys in the + app-native `vpn://` shape for AmneziaVPN. +- **Multi-protocol GUI**: a "multi-protocol backends" panel in Server settings with + official Hysteria/Amnezia logos, backend profile cards alongside VLESS cards + (create flow with an explicit protocol choice — a new profile receives keys only + from the chosen backend), per-backend QR (including "QR · AmneziaVPN"), expiry, + revoke and delete with immediate backend-key revocation on profile deletion. +- **HAPP client routing** (`bypass` CLI + generator): managed HAPP profile can carry + split routing from bypass groups, with placeholders (`{{GEOIP_URL}}`, `{{GEOSITE_URL}}`, + `{{LAST_UPDATED}}`) resolved per subscriber — geo databases are downloaded through the + subscription itself. +- **quickstart `http_tls` graceful degradation**: when Let's Encrypt cannot validate + http-01 for the IP (`Connection reset by peer` — the hoster filters port 80), the + deploy no longer dead-ends: markers switch to `http_tls`, the subscription stays + loopback-only (there is no public subscription URL at all — the GUI loads keys over + SSH from `127.0.0.1:8080`), a self-signed certificate is generated for future backends, + and the result JSON carries `degraded:true` / `tls_mode:"http_tls"` / `certbot_reason`. + Re-running quickstart after the hoster unblocks port 80 restores HTTPS. +- SNI candidates updated from the 03.09.2026 reachability measurements; the default SNI + for new profiles is `www.cloudflare.com`. +- Server country in key names: the HAPP profile fragments are labelled with a country + flag and name (`🇫🇮 Finland · happ-...`) resolved from the server IP geo databases. +- `validation/` grew from 28 to 34 scripts: backend registry, Hysteria 2 lifecycle, + AmneziaWG junk/renderers/lifecycle, HAPP client routing, legacy profile port sync, + quickstart `http_tls` fallback, apt-lock race, profile revoke/expire CLI. +- Branch choice for server updates: the "Update Xrayebator" button became a menu — + "Auto — as pinned on the server" (previous behavior), "main — latest stable release", + "dev — every feature first" (may be unstable): a server can be moved to the dev + manager before the merge without polluting main; switching back is an update from main. + +### Fixed + +- `revoke --full` crashed on single-route profiles (invalid jq path). +- `xrayebator profile-delete` printed colored status lines into stdout JSON; the CLI + path is JSON-clean now and backend grants are revoked in the same command (no + orphaned AWG peers or Hysteria passwords). +- Backend keys are granted only for the explicitly chosen protocol — creation no longer + issues keys from every installed backend. +- AWG ports moved out of the ephemeral range and ufw reloads are avoided during batch + firewall updates. +- Dead duplicate function definitions in the single-file manager removed (a shadowed + validator whose body would have clobbered subscription markers if ever called). +- The GUI no longer shows a dead `https://IP:8443` subscription URL on degraded + (`http_tls`) servers: no public link is displayed or saved at all, keys are fetched + over SSH, and the server is stored as "partially configured" with a degraded badge. +- Backend key creation/revoke/expiry dialogs match the VLESS card semantics + (danger-soft confirmations, device notes, expiry presets). + ## [0.6.0] - 2026-10-01 Access control in the panel: subscription revocation (link-only or full), profile expiry diff --git a/CLAUDE.md b/CLAUDE.md index c8d8aae6..101a73f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Xrayebator — automated Xray Reality VPN manager for bypassing DPI censorship i ## Validation There IS automated test coverage (despite what older notes said): -- **`validation/`** — 28 Bash test scripts, including `test-main-readiness-regressions.sh`, covering migrations, VLESS URL generation, transaction safety, dedup, firewall, menu numbering, the bypass/sni-change/port-change/profile-revoke/profile-expire CLIs, update branch pinning, quickstart, email/inspect regressions, apt-lock race regressions and audit regressions. They run on the host (`bash validation/test-*.sh`); CI installs `jq`, `uuidgen` and `ripgrep` on Ubuntu. A bare Windows Git Bash checkout is not equivalent to the Linux environment. +- **`validation/`** — 34 Bash test scripts, including `test-main-readiness-regressions.sh`, covering migrations, VLESS URL generation, transaction safety, dedup, firewall, menu numbering, the backend registry, the Hysteria 2 backend renderers/lifecycle, the AmneziaWG backend junk/renderers, the bypass/sni-change/port-change/profile-revoke/profile-expire CLIs, the HAPP client-routing generator, update branch pinning, quickstart, email/inspect regressions, apt-lock race regressions, the quickstart http_tls fallback and audit regressions. They run on the host (`bash validation/test-*.sh`); CI installs `jq`, `uuidgen` and `ripgrep` on Ubuntu. A bare Windows Git Bash checkout is not equivalent to the Linux environment. - **`gui-legacy/tests/`** — 16 pytest modules covering SSH, deploy, connection, subscription and TUN runtime (legacy PySide6 GUI). Run with the GUI venv: `gui-legacy/.venv/Scripts/python -m pytest gui-legacy/tests`. - **GUI (Electron)** — Vitest unit tests in `tests/`: `npm test`, plus `npm run typecheck`. - **CI** — `.github/workflows/ci-linux.yml` runs the full `validation/` suite; `.github/workflows/gui-release.yml` runs `ruff` + `pytest gui-legacy/tests` and builds Windows/macOS bundles; `.github/workflows/release.yml` ships the Electron app. @@ -135,13 +135,13 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `xrayebator update` — update only the Xray-core binary; `xrayebator update ` self-updates the manager from the canonical raw branch, pins that branch in `/usr/local/etc/xray/.current_branch` (the GUI updater reads this file), then updates Xray-core. - `xrayebator-update [branch]` — separate full `update.sh` lifecycle workflow; without a branch it displays `.current_branch` and opens interactive branch selection. -- `xrayebator quickstart --email ` — UI CLI used by the desktop app: runs the broad setup/migration path, provisions the subscription endpoint, creates a standard **schema-v3 multi-route** HAPP profile (7 routes including `xhttp-legacy`), and prints JSON with `subscription_url`. The implementation currently tolerates migration failures in this non-interactive path; verify the resulting profile and services after deployment. Email mode is explicit: `quickstart --without-email` runs the same path but registers Certbot/ACME with `--register-unsafely-without-email` (no renewal notices, no email-based account recovery; never substitute a fake address). The GUI passes exactly one of the two forms. -- `xrayebator inspect --json` — read-only install probe for the GUI "connect existing server" import: reports manager/Xray/profiles/subscription markers as one JSON object on stdout (diagnostics to stderr only). It must not migrate, install, restart, open firewall or write anything; `validation/test-quickstart-email-and-inspect.sh` guards these invariants statically. +- `xrayebator quickstart --email ` — UI CLI used by the desktop app: runs the broad setup/migration path, provisions the subscription endpoint, creates a standard **schema-v3 multi-route** HAPP profile (7 routes including `xhttp-legacy`), and prints JSON with `subscription_url`. The implementation currently tolerates migration failures in this non-interactive path; verify the resulting profile and services after deployment. Email mode is explicit: `quickstart --without-email` runs the same path but registers Certbot/ACME with `--register-unsafely-without-email` (no renewal notices, no email-based account recovery; never substitute a fake address). The GUI passes exactly one of the two forms. If Let's Encrypt cannot validate the IP over http-01 (hoster filters port 80 — "Connection reset by peer" from validation hosts), quickstart degrades to `http_tls`: markers switch to that mode, the subscription is published over HTTP from the loopback handler, a self-signed cert is generated for future backends, and the result JSON carries `degraded:true`, `tls_mode:"http_tls"` and `certbot_reason`; the renew timer is NOT installed in this mode, and rerunning quickstart after port 80 is unblocked issues the LE certificate and restores ip_tls (guarded by `validation/test-quickstart-tls-fallback.sh`). +- `xrayebator inspect --json` — read-only install probe for the GUI "connect existing server" import: reports manager/Xray/profiles/subscription markers as one JSON object on stdout (diagnostics to stderr only). It must not migrate, install, restart, open firewall or write anything; `validation/test-quickstart-email-and-inspect.sh` guards these invariants statically. Since the multi-protocol stage it also carries a secrets-free `backends` summary (`{: {installed, version, port}}`) read from the backend registry only when the file already exists — inspect never creates it. - `xrayebator happ-setup` — reduced existing-install HAPP path; ensures the subscription service and a usable multi-route profile, verifies a real public TLS endpoint when subscription markers are missing, and prints JSON with `subscription_url`. It does not have the same migration breadth as quickstart. - `xrayebator probe-test` — probe-test candidate SNIs from `sni_list.txt` and print reachability scores. -- `xrayebator profiles` — print all profiles as a flat JSON array (used by the GUI "Server settings" page); each entry carries `expire` (epoch seconds, `0` = unlimited), server-local `expire_date` (`YYYY-MM-DD`) for display, and `expire_disabled`. `expire_supported` is present (`true`) only on servers with expiry enforcement; its absence means the server predates the feature and the GUI must not offer expiry. -- `xrayebator profile-create --name NAME [--transport T] [--port P] [--count N] [--expire DATE]` — create 1..N profiles non-interactively (names `name`, `name-2`, ...). `--expire` accepts `YYYY-MM-DD[ HH:MM]`, epoch seconds or 13-digit milliseconds; date-only expiry is inclusive through `23:59:59` in server-local time, while an explicit time uses server-local time as given. Dates/times with leading-zero fields such as `09` are parsed as decimal, not octal. A past expiry is rejected. Emits `{"ok":true,"names":[...],"errors":[...]}`; `ok` stays `true` even when some profiles already exist (they land in `errors`). -- `xrayebator profile-delete --name NAME` — delete a profile, emits `{"ok":true,"name":"..."}`. Inbound/firewall cleanup happens automatically. +- `xrayebator profiles` — print all profiles as a flat JSON array (used by the GUI "Server settings" page); each entry carries `expire` (epoch seconds, `0` = unlimited), server-local `expire_date` (`YYYY-MM-DD`) for display, `expire_disabled`, and since the multi-protocol stage the additive `backends` object (per-profile hysteria2/awg grants — operator-level credentials, same trust level as the already-exposed `sub_token`). `expire_supported` is present (`true`) only on servers with expiry enforcement; its absence means the server predates the feature and the GUI must not offer expiry. +- `xrayebator profile-create --name NAME [--transport T] [--port P] [--count N] [--expire DATE]` — create 1..N profiles non-interactively (names `name`, `name-2`, ...). `--expire` accepts `YYYY-MM-DD[ HH:MM]`, epoch seconds or 13-digit milliseconds; date-only expiry is inclusive through `23:59:59` in server-local time, while an explicit time uses server-local time as given. Dates/times with leading-zero fields such as `09` are parsed as decimal, not octal. A past expiry is rejected. Emits `{"ok":true,"names":[...],"errors":[...]}`; `ok` stays `true` even when some profiles already exist (they land in `errors`). The GUI additionally pre-checks a name collision before issuing the create call (deletion is per-profile: unique names keep it precise, and the deleted profile's backend keys are revoked immediately — see the lifecycle bullets above). +- `xrayebator profile-delete --name NAME` — delete a profile, emits `{"ok":true,"name":"..."}` (stdout carries only the JSON — the `backup_config` status line is silenced to stderr like the other helpers). Inbound/firewall cleanup happens automatically, and the deleted profile's backend grants are revoked in the same command: the AWG peer is regenerated out of `awg0.conf`, the Hysteria password out of `server.yaml` — no orphaned credentials; the name is immediately free for reuse. - `xrayebator profile-revoke --name NAME [--full]` — reissue the subscription `sub_token`; `--full` also rotates the uuid in every inbound of the profile, so already-downloaded configs stop connecting. Transaction order matters: config + `safe_restart_xray` first, then the profile JSON, and a failed profile write rolls the config back from the transaction backup. - `xrayebator profile-expire --name NAME --expire DATE|epoch|none` — set/extend/clear a profile expiry (`.expire` epoch seconds); date-only expiry is inclusive through `23:59:59` in server-local time; an explicit time is also interpreted in server-local time. Applied immediately (an expired profile loses its client, an extension restores it). - `xrayebator expire-check` — batch enforcement of every due expiry; idempotent (no changes ⇒ no Xray restart), driven by `xrayebator-expire.timer` every 10 minutes. Disabling keeps the inbounds alive (a fresh inbound would change the shortId and kill issued URLs) and snapshots clients into `.expire_clients` for restoration. @@ -150,6 +150,11 @@ Apart from the interactive menu (`sudo xrayebator`), the script exposes subcomma - `xrayebator sni-list` — print the SNI candidates from `sni_list.txt` grouped by category, emits JSON (used by the GUI SNI dialog). - `xrayebator port-change --name NAME [--route R] --port PORT|random` — change the port for a profile; updates the inbound, firewall, subscription and all profiles on the port, emits JSON (reconnect is required). - `xrayebator bypass list|add --domain D|remove --domain D|reset|bundle [--group a,b,c]` — manage bypass routing groups (JSON). Server-side only: it exists for cascade deployments, where the catch-all points at the upstream; without a cascade the built-in catch-all already sends everything direct. +- `xrayebator backend-status` — print the multi-protocol backend registry status as JSON (`{"ok":true,"backends":{...}}`). Backend state lives in the neutral root `/usr/local/etc/xrayebator/` (`backends.json` registry 644 root — no secrets, readable by the `xray` service account; `backends//` runtime state with secrets in 750/640); `/usr/local/etc/xray/*` stays Xray-only. Slice 1 of the multi-protocol stage (2026-10) ships the registry with no backends; `hysteria2` and `awg` entries appear as those backends land. `_backend_apply_profile_lifecycle ` is the single lifecycle choke point for backend grants; per-profile backend credentials live in the profile's additive `.backends` object, so the profile JSON remains the single source of truth for revoke/expire. Guarded by `validation/test-backend-registry.sh` (sources the script in source-mode with an overridden `BACKENDS_ROOT`). +- `xrayebator hysteria2-install [--port P] [--grant-all]` / `hysteria2-uninstall` / `hysteria2-status` / `hysteria2-grant --name N` / `hysteria2-subbody --on|--off` / `hysteria2-link --name N` — Hysteria 2 UDP backend (multi-protocol slices 2–3, menu item 11). Install downloads the binary from `HyNetworks/hysteria` releases (tag format `app/vX.Y.Z`, sha256 from the API `digest` field), creates the dedicated `hysteria` system user, writes its own `hysteria-server.service` (CAP_NET_BIND_SERVICE, `Restart=on-failure`), QUIC sysctl buffers, and `/usr/local/etc/xrayebator/backends/hysteria/server.yaml`. TLS is adaptive: a subscription Let's Encrypt cert is copied into the backend dir with a certbot renewal deploy-hook, otherwise a self-signed cert is generated (clients then use `insecure=1`). CLI commands print only JSON on stdout; exit code `2` from the workhorse means "already installed / not installed" and the wrapper reports `already: true`. +- Hysteria 2 grants (slice 3): per-profile credentials live in the profile's `.backends.hysteria2` object (`username` = profile name, `password` = 32 hex chars) — the profile JSON is the single source of truth; `server.yaml` is always **regenerated** from profiles by `_hysteria2_regen_config` (compact users map, filter `.expire_disabled == false`, so `expired`/`restored` events are just a regen) and applied via `_hysteria2_apply_config` (restart with rollback). `_backend_apply_profile_lifecycle` is wired after the Xray part of `create_profile` / `_profile_cli_create_one` (created — **no-op**: backend keys are NOT auto-issued on creation, the operator picks the protocol explicitly via the GUI create flow or explicit grant CLI — otherwise every new profile would receive keys from ALL installed backends), `profile_delete_command` (deleted), `profile_revoke_command` (revoked → password rotation), and `_expire_disable_profile`/`_expire_enable_profile` (expired/restored). Install asks to grant all existing profiles interactively; the CLI is non-interactive by default and opt-in via `--grant-all`. The pure builder `_hysteria2_profile_link_pure` produces `hysteria2://user:pass@host:port/?sni=…&insecure=0|1#name`; the subhttp handler appends it to BOTH the generic (v2rayNG) and the HAPP subscription bodies, gated by the registry `sub_body` flag (kill-switch without service restart). Migration `subhttp_hysteria2_2026` regenerates the handler on existing servers and is registered in BOTH `main_menu` and `quickstart` (parity test). The registry file is 644 root (no secrets) so the `xray` service account can read it from subhttp; secrets live in the 750 backend dirs. HAPP-body acceptance with a hysteria2 line is verified in the live spike, not CI. +- `xrayebator awg-install [--grant-all]` / `awg-uninstall` / `awg-status` / `awg-grant --name N` / `awg-conf --name N` / `awg-31 --on|--off` — AmneziaWG 2.0/3.1 system-VPN backend (multi-protocol slices 4–5 + 3.1 stage, menu items 12/13). Kernel module via DKMS per the AmneziaWG README: primary path is the `amnezia/ppa` PPA (`apt install amneziawg`), fallback is a manual source build (kernels ≥5.6 need the FULL `linux-source` tree linked as the module's `kernel` symlink — headers alone are not enough). The backend is registered in the registry only after `modprobe amneziawg` + `lsmod` confirm the module and `awg show awg0` confirms the interface. Server state: `backends/awg/server-params.json` (0600, keys/port/iface/junk) and `awg0.conf` (0600). AWG 3.1 is implemented and ON by default: the generator emits `S1`–`S4` (≥ 12), `HeaderProtectionKey` (random, shared with every client conf) and `RandomTrailers = on` — the `awg` parser (kernel UAPI) accepts booleans as `on`/`off`, NOT `true`/`false`; `DisableCookies` is available via `awg-31 --disable-cookies` (local flag, off by default). `awg-31` toggles the format with an explicit operator warning that every issued client `.conf` must be re-downloaded (clients need AmneziaVPN ≥ 5.0.1.5) and regenerates the interface with rollback; `_awg_ensure_31_params` upgrades legacy (pre-3.1) server params in place. Junk params mirror the Amnezia defaults — the dialect the official AmneziaVPN apps (phone and desktop) apply reliably; their go-tunnel applies custom junk incompletely: Jc=5, Jmin=10, Jmax=50, H1–H4=1..4 (WG magic), S1/S2 random 12..150, S3/S4 random 12..64, all unique with S1+56≠S2. The desktop GUI additionally delivers keys in the app-native `vpn://` shape (`amnezia-awg2` container, server-side junk fields, `protocol_version`; see shared/awg.ts `buildAwgVpnConfig`) — a raw `.conf` pasted into AmneziaVPN does not reach its tunnel intact. `ip_forward` is set via `/etc/sysctl.d/99-xrayebator-awg.conf`. Uninstall tears down the interface and files but leaves the packages/module in the system (documented in output). +- AWG peers (slice 5): per-profile peer credentials live in the profile's `.backends.awg` object (`client_private_key`, `client_public_key`, `preshared_key`, `address` `10.8.1.x`, `created`) — profile JSON is the single source of truth; `awg0.conf` `[Peer]` sections are **regenerated** from profiles (`_awg_regen_config`, filter `.expire_disabled == false`, so expired/restored events are just a regen) and applied via `_awg_apply_config` (interface restart with rollback; the restart briefly drops the tunnel for all peers — accepted for stage 1, grants/revokes are rare). Address allocation picks the first free `.2..254` honoring addresses already issued to profiles. The lifecycle choke point dispatches to `_awg_on_profile_event` automatically for installed backends — deleted/revoked/expired/restored need no extra wiring in profile flows (created is a no-op: no auto-grant, keys are issued only by explicit `awg-grant`; the `deleted` case regenerates unconditionally because the caller removes the profile file BEFORE the lifecycle call). Client config (`_awg_client_conf_pure`) carries the client keypair, the server pubkey/endpoint, junk params copied from the server, `AllowedIPs = 0.0.0.0/0, ::/0` (full tunnel) and `PersistentKeepalive = 25`; `awg-conf --name N` returns it as JSON `{ok, name, conf}` for the GUI, the menu prints it with an optional QR. Peer events: revoked = keypair+PSK rotation with the address kept (old client configs stop connecting). Package installation itself (network + DKMS build) and a real client connection are verified in the live spike, not CI. CLI JSON hygiene: `profile-create`/`profile-delete` **must** print only JSON on stdout. The shared helpers (`backup_config`, `add_inbound`, `open_firewall_port`, `safe_restart_xray`, `close_firewall_port`) print colored status lines that would corrupt the parse, so the CLI paths redirect stdout→stderr around those calls (`exec 3>&1; exec 1>&2 ... exec 1>&3`). Keep it that way when editing. diff --git a/README.md b/README.md index a126fe12..96e8bc1d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@

inbounds · profiles · subscription · -bypass · cascade +bypass · cascade · hysteria2 · +amneziawg

@@ -28,8 +29,10 @@ Xrayebator installs Xray-core, brings up Reality inbounds on random ports, and provisions a standard schema-v3 HAPP profile of seven routes for a new setup. Existing seven-route profiles can be reused only when they have enough live routes, so inspect the profile JSON when debugging labels or schema. The client -receives the routes as a single HTTPS subscription link. Current server line — 3.0; the optional Electron -desktop app is versioned separately. +receives the routes as a single HTTPS subscription link. As of the multi-protocol stage, optional +Hysteria 2 (fast UDP) and AmneziaWG 2.0/3.1 (system VPN) backends can be installed on the same VPS from +menu items 11–13, with per-profile grants wired into the shared revoke/expire lifecycle. Current server +line — 3.0; the optional Electron desktop app is versioned separately.

diff --git a/README.ru.md b/README.ru.md index 95c07d99..466d5d87 100644 --- a/README.ru.md +++ b/README.ru.md @@ -6,7 +6,8 @@

инбаунды · профили · подписка · -bypass · каскад +bypass · каскад · hysteria2 · +amneziawg

@@ -28,7 +29,10 @@ Xrayebator ставит Xray-core, поднимает Reality-инбаунды на случайных портах и для новой установки создаёт стандартный schema-v3 HAPP-профиль из семи маршрутов. Существующий семимаршрутный профиль может быть переиспользован при наличии живых маршрутов, поэтому при отладке label/schema проверяйте -profile JSON. Клиент получает маршруты одной HTTPS-ссылкой подписки. Актуальная серверная линия — 3.0; +profile JSON. Клиент получает маршруты одной HTTPS-ссылкой подписки. Начиная с мультипротокольного +этапа, на тот же VPS опционально ставятся бэкенды Hysteria 2 (быстрый UDP) и AmneziaWG 2.0/3.1 +(системный VPN) — пункты меню 11–13, гранты per-profile включены в общий жизненный цикл +revoke/expire. Актуальная серверная линия — 3.0; опциональное Electron-десктоп-приложение имеет отдельную версию.

diff --git a/README.zh-CN.md b/README.zh-CN.md index fbb64663..b99c5b90 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,8 @@

入站 · 配置档 · 订阅 · -分流 · 级联 +分流 · 级联 · hysteria2 · +amneziawg

@@ -27,7 +28,9 @@ 一个 bash 脚本,把干净的 VPS 变成私人 VLESS Reality 服务器。
Xrayebator 安装 Xray-core,在随机端口上建立 Reality 入站,并为新安装创建包含七条线路的标准 schema-v3 HAPP 配置档。已有七线路配置档在满足存活线路数量时可能被复用,因此排查 label 或 schema -时请检查配置档 JSON。客户端通过一条 HTTPS 订阅链接获取线路。当前服务器版本线为 3.0;可选 +时请检查配置档 JSON。客户端通过一条 HTTPS 订阅链接获取线路。自多协议阶段起,同一台 VPS 可选装 +Hysteria 2(高速 UDP)与 AmneziaWG 2.0/3.1(系统级 VPN)后端——菜单项 11–13,每配置档授权纳入统一的 +revoke/expire 生命周期。当前服务器版本线为 3.0;可选 Electron 桌面应用单独版本化。

diff --git a/docs/architecture.md b/docs/architecture.md index 98ac09bf..e5fae616 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ configuration and systemd unit form the HAPP subscription path. The active deskt /usr/local/etc/xray/ ├── config.json # inbounds, outbounds, routing and DNS -├── profiles/.json # profile metadata, subscription token, expiry (.expire) +├── profiles/.json # profile metadata, subscription token, expiry (.expire), backend grants (.backends) ├── upstreams/cascade.json # cascade upstream parameters ├── backups/ # config backups made before runtime mutations ├── .private_key / .public_key # Reality keys @@ -62,10 +62,18 @@ configuration and systemd unit form the HAPP subscription path. The active deskt ├── .current_branch # manager branch used by lifecycle updates └── migration markers # records of completed one-time migrations +/usr/local/etc/xrayebator/ # multi-protocol backend root (neutral, Xray paths untouched) +├── backends.json # backend registry: installed/version/port/flags (644, no secrets) +└── backends/ + ├── hysteria2/ # server.yaml (regenerated from profiles), certs, placeholder credential + └── awg/ # awg0.conf (0600), server-params.json (0600) + /usr/local/share/xray/ # geoip.dat and geosite.dat /var/log/xray/ # runtime log directory written by the Xray service /etc/systemd/system/xray.service.d/security.conf /etc/systemd/system/xrayebator-sub.service +/etc/systemd/system/hysteria-server.service # Hysteria 2 backend (optional) +/etc/systemd/system/awg-quick@awg0.service # AmneziaWG backend (optional, from amneziawg-tools) /etc/nginx/sites-available/xrayebator-sub ``` @@ -102,13 +110,17 @@ The base is built from the saved subscription markers: ```text https:///sub/<32-hex-token> # public TLS on 443 https://:8443/sub/<32-hex-token> # public TLS on another port +http://:8080/sub/ # http_tls fallback (LE could not validate http-01) http://127.0.0.1:8080/sub/ # local-only fallback ``` The interactive HAPP setup can select the public port and `_subscription_base_url` preserves that choice. The non-interactive `quickstart --email
` and `quickstart --without-email` IP-TLS paths provision nginx, certificate and markers on `8443`, then emit JSON containing `subscription_url` for that -endpoint. Without an email, Certbot is explicitly told to register without an ACME contact; renewal +endpoint. When Let's Encrypt cannot validate http-01 (the hoster filters port 80 for foreign sources), +quickstart degrades to `http_tls`: the markers switch to that mode, the subscription is published over +HTTP from the loopback handler and the JSON carries `degraded:true`; rerunning the deploy after port 80 +is unblocked restores ip_tls. Without an email, Certbot is explicitly told to register without an ACME contact; renewal notices and email-based recovery are unavailable. The token is stored in the profile as `sub_token`; revoke rotates it and invalidates the previous URL, while a full revoke (`profile-revoke --full`) also changes the uuid in every inbound of the profile so already-downloaded configurations stop connecting. @@ -132,7 +144,32 @@ or quickstart when the required routes are absent. A profile with no live inboun The handler also serves the token-protected `geoip.dat` and `geosite.dat` resources required by the managed HAPP routing profile. HAPP receives its routing metadata, while v2rayNG and v2rayN receive -the compatible VLESS body without HAPP-only metadata. +the compatible VLESS body without HAPP-only metadata. When the Hysteria 2 backend is installed and +the profile has a grant, the handler additionally appends one `hysteria2://` line to both the HAPP +and the generic subscription bodies, gated by the registry flag `sub_body`; expired or disabled +profiles never get the line. + +## Multi-protocol backends + +Beyond Xray, the manager can install additional transports on the same VPS. All backend state lives +in the neutral root `/usr/local/etc/xrayebator/` (registry `backends.json` plus per-backend +directories); the Xray state paths are untouched. Per-profile backend credentials live in the +profile's additive `.backends` object, which makes the profile JSON the single source of truth: +server-side configs (`backends/hysteria2/server.yaml`, `backends/awg/awg0.conf`) are always +regenerated from profiles, and profile lifecycle events (create, delete, revoke, expire, restore) +propagate through `_backend_apply_profile_lifecycle` to every installed backend. + +- **Hysteria 2** — fast UDP transport. Own systemd unit `hysteria-server.service` running as the + dedicated `hysteria` user with `CAP_NET_BIND_SERVICE`; UDP 443 by default; adaptive TLS (the + subscription Let's Encrypt certificate plus a renewal deploy-hook, or a self-signed certificate); + `auth: userpass` with one credential per profile and a placeholder user while the map would + otherwise be empty (Hysteria rejects an empty map). +- **AmneziaWG 2.0** — system VPN. Kernel module via DKMS (PPA `amnezia/ppa` primary, source build + fallback), interface `awg0` through `awg-quick@awg0`, subnet `10.8.1.0/24`, per-profile peers + (keypair + preshared key + address), `ip_forward` and MASQUERADE on the default-route interface. + The distro unit reads `/etc/amnezia/amneziawg/awg0.conf`, which is a symlink to the managed file. +- Both backends are optional, installed and removed from menu items `11`–`13` or the `hysteria2-*` + / `awg-*` CLI commands, and are never installed by default. ## Update paths diff --git a/docs/configuration.md b/docs/configuration.md index c29b3b08..5aa38e1b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,12 +93,16 @@ The interactive menu uses these exact meanings: | `4` | Manage a profile: SNI, client fingerprint, port and advanced settings | | `5` | Upgrade a single profile to post-quantum XHTTP + Reality | | `6` | HAPP subscription: provision public/local publishing, URL/QR, revoke and HAPP settings; the managed profile has 7 routes and the published list has 6 | +| `7` | Bypass routing: send selected domains (banks, marketplaces, Steam…) direct instead of through the tunnel | | `8` | Cascade and upstream nodes | | `9` | Custom domain and self-steal stub | | `10` | Set up an outbound server so another VPS can use this server as a foreign cascade node | +| `11` | Hysteria 2 backend: install/status/client link/uninstall (fast UDP transport) | +| `12` | AmneziaWG 2.0 backend: install/status/client conf/uninstall (system VPN) | +| `13` | Status of all multi-protocol backends | | `0` | Exit | -Actions are numbered consecutively from `1` to `10`; `0` exits the program. SNI and port are +Actions are numbered consecutively from `1` to `13`; `0` exits the program. SNI and port are shared inbound settings, so changing either can affect other profiles on that port. The fingerprint is a client-side profile/route setting; changing it does not restart Xray or alter other routes. @@ -114,21 +118,35 @@ is a client-side profile/route setting; changing it does not restart Xray or alt | `sudo xrayebator quickstart --without-email` | Same new-server path without an ACME contact email; Certbot uses `--register-unsafely-without-email`, so no renewal notices or email-based account recovery are available | | `sudo xrayebator inspect --json` | Read-only GUI import probe: reports manager, Xray, profile and subscription markers without installing, migrating or changing services/configuration | | `sudo xrayebator happ-setup` | Reduced existing-install HAPP path: ensures the subscription service and a usable multi-route profile, but does not replace the endpoint prerequisite; when `.subscription_domain` or `.subscription_port` is missing, it verifies a real public TLS endpoint before writing markers and otherwise fails | +| `quickstart` http_tls mode | If Let's Encrypt cannot validate the IP over http-01 (`Connection reset by peer` from validation hosts — usually the hoster filters port 80 for foreign sources), the deploy does not fail: the subscription is published over HTTP (port 8080 outbound, token gate and 404 without token preserved), a self-signed certificate is generated for future backends, and the result JSON carries `degraded:true`, `tls_mode:"http_tls"` and the Certbot reason. The GUI stores the server as *Partially configured* and loads keys over SSH from the loopback handler; the ACME challenge location on port 80 stays in place — rerunning quickstart after the port is unblocked issues the LE certificate and switches the subscription back to HTTPS | | `sudo xrayebator profiles` | Print all server profiles as a JSON array for the desktop GUI Server Settings page; expiry includes both epoch seconds (`expire`) for enforcement and the server-local calendar date (`expire_date`) for display, so clients in another timezone still see the selected date | -| `sudo xrayebator profile-create --name NAME [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire DATE]` | Create one or more profiles non-interactively; `--expire` accepts `YYYY-MM-DD[ HH:MM]`, epoch seconds or 13-digit milliseconds. A date without time is inclusive through `23:59:59` in the server's local timezone; an explicit time uses that server-local time. Past expiries are rejected; prints `{"ok":true,"names":[...],"errors":[...]}` | -| `sudo xrayebator profile-delete --name NAME` | Delete a profile non-interactively; prints `{"ok":true,"name":"..."}` | +| `sudo xrayebator profile-create --name NAME [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire DATE]` | Create one or more profiles non-interactively; `--expire` accepts `YYYY-MM-DD[ HH:MM]`, epoch seconds or 13-digit milliseconds. A date without time is inclusive through `23:59:59` in the server's local timezone; an explicit time uses that server-local time. Past expiries are rejected; existing names are never overwritten — they are reported in `errors` (the GUI also pre-checks a name collision before creating, since deletion is per-profile and unique names keep it precise); prints `{"ok":true,"names":[...],"errors":[...]}` | +| `sudo xrayebator profile-delete --name NAME` | Delete a profile non-interactively; the deleted profile's backend grants are revoked immediately (the AWG peer is dropped from `awg0.conf`, the Hysteria password from `server.yaml` — no orphaned credentials) and the name is free for reuse; prints `{"ok":true,"name":"..."}` | | `sudo xrayebator profile-revoke --name NAME [--full]` | Reissue the subscription link: new `sub_token`; with `--full` also a new uuid in every inbound of the profile (already-downloaded configs are cut off); prints JSON | | `sudo xrayebator profile-expire --name NAME --expire DATE\|epoch\|none` | Set, extend or remove a profile expiry; a date without time is inclusive through `23:59:59` in the server's local timezone, while an explicit time is used as given in that timezone; applied immediately (a passed expiry removes the client, an extension restores it); prints JSON | | `sudo xrayebator expire-check` | Apply every due expiry in one batch; idempotent and never restarts Xray without changes. Driven by the `xrayebator-expire.timer` unit every 10 minutes | | `sudo xrayebator fp-change --name NAME [--route R] --fp FINGERPRINT` | Change the client fingerprint for one profile route; prints JSON | | `sudo xrayebator sni-change --name NAME [--route R] --sni SNI` | Change the shared inbound SNI and synchronise profiles on that port; prints JSON | -| `sudo xrayebator sni-list` | Print SNI candidates grouped by category for the GUI SNI dialog; prints JSON | +| `sudo xrayebator sni-list` | Print SNI candidates grouped by category for the GUI SNI dialog; prints JSON. The first uncommented line of `sni_list.txt` is the default SNI for new profiles (`www.cloudflare.com`) | | `sudo xrayebator port-change --name NAME [--route R] --port PORT\|random` | Change the inbound port, firewall and subscription metadata; reconnect the client; prints JSON | | `sudo xrayebator bypass list` | Print current bypass domain rules as JSON | | `sudo xrayebator bypass add --domain D` | Add a domain to bypass rules | | `sudo xrayebator bypass remove --domain D` | Remove a domain from bypass rules | | `sudo xrayebator bypass reset` | Clear all custom bypass rules | | `sudo xrayebator bypass bundle [--group a,b,c]` | Apply the default bypass groups; without `--group`, apply all groups | +| `sudo xrayebator backend-status` | Print the multi-protocol backend registry status as JSON (`{"ok":true,"backends":{…}}`) | +| `sudo xrayebator hysteria2-install [--port P] [--grant-all]` | Install the Hysteria 2 UDP backend: binary from `HyNetworks/hysteria` releases (SHA-256 verified), dedicated `hysteria` service user, own systemd unit with `CAP_NET_BIND_SERVICE`, QUIC sysctl buffers, adaptive TLS (subscription Let's Encrypt cert with a renewal deploy-hook, otherwise self-signed); default UDP port 443; prints JSON (`already: true` when installed) | +| `sudo xrayebator hysteria2-uninstall` | Remove the Hysteria 2 backend (service, config, certs, firewall rule); prints JSON | +| `sudo xrayebator hysteria2-status` | Hysteria 2 backend status as JSON | +| `sudo xrayebator hysteria2-grant --name N` | Issue a per-profile Hysteria 2 credential (stored in the profile's `.backends.hysteria2`); the server config is regenerated from all profiles; prints JSON | +| `sudo xrayebator hysteria2-subbody --on\|--off` | Kill switch for the `hysteria2://` lines in both subscription bodies: the flag lives in the backend registry and the handler re-reads it on every request, no service restart; prints JSON | +| `sudo xrayebator hysteria2-link --name N` | Print the `hysteria2://` link of a profile with a grant as JSON `{ok, name, link}` (naming mirrors the VLESS routes, with the country flag) | +| `sudo xrayebator awg-install [--grant-all]` | Install the AmneziaWG 2.0 system-VPN backend: kernel module via DKMS (PPA `amnezia/ppa`, source-build fallback), `awg0` interface with a random high UDP port, junk parameters matching the Amnezia defaults, `ip_forward` + MASQUERADE; prints JSON | +| `sudo xrayebator awg-uninstall` | Remove the AmneziaWG backend (interface, config, symlink, firewall rule; packages/module stay in the system); prints JSON | +| `sudo xrayebator awg-status` | AmneziaWG backend status as JSON | +| `sudo xrayebator awg-grant --name N` | Issue a per-profile peer (keypair + preshared key + `10.8.1.x` address in the profile's `.backends.awg`); `awg0.conf` is regenerated; prints JSON | +| `sudo xrayebator awg-conf --name N` | Print the client `.conf` for a profile peer as JSON `{ok, name, conf}` — full-tunnel AllowedIPs, server junk parameters, endpoint; import into the AmneziaWG/AmneziaVPN client | +| `sudo xrayebator awg-31 --on\|--off` | Toggle the AWG 3.1 config format (`HeaderProtectionKey`, `RandomTrailers`, `S3`/`S4`); 3.1 is on by default for new installs, the toggle upgrades legacy server params in place, warns that every issued client `.conf` must be re-downloaded (AmneziaVPN ≥ 5.0.1.5) and regenerates the interface with rollback | | `sudo xrayebator-update [branch]` | Run the full `update.sh` project lifecycle update; without a branch, display `.current_branch` and open the interactive branch selector; with a branch, use that explicit branch | | `sudo xrayebator-uninstall` | Remove the service and installation | @@ -157,6 +175,19 @@ best-effort; verify markers, the profile JSON and service status after deploymen `quickstart --without-email` performs the same broad setup and endpoint provisioning as the email form, but registers the ACME account with `--register-unsafely-without-email`; Certbot renewal notices and email-based account recovery are unavailable. +If Let's Encrypt cannot validate the IP over http-01 — the deployment log shows +`Connection reset by peer` for the challenge fetch, typically because the hoster +filters port 80 for foreign sources — quickstart does not fail: it degrades to +`http_tls` mode. The subscription handler stays loopback-only: there is **no +public subscription URL at all** — the GUI loads keys over SSH from +`http://127.0.0.1:8080/sub/` on the server itself. A self-signed +certificate is generated for future backends, and the result JSON carries +`degraded:true`, `tls_mode:"http_tls"` and the Certbot reason. The server stays +in the panel as *Partially configured* with a degraded badge, and the ACME +challenge location on port 80 stays in place — rerunning quickstart +after the hoster unblocks port 80 issues the Let's Encrypt certificate and +switches the subscription back to HTTPS (the path is idempotent). + `inspect --json` is the GUI's read-only import probe. It reports whether this is an Xrayebator installation, Xray/profile/service markers and saved subscription metadata; it does not run migrations, create profiles, change configuration, restart services or edit firewall rules. `happ-setup` is the reduced path for an existing installation. It runs only the critical migrations, @@ -175,7 +206,10 @@ profile when `xhttp-legacy`, `xhttp-pq` or the expected seven-route shape is mis The active Electron app is a CLI front-end over SSH, not a complete replacement for the terminal menu. It deploys with `quickstart`, refreshes the saved `subscription_url`, and exposes profile -SNI, fingerprint, port, update and uninstall operations. `probe-test`, HAPP setup, cascade, +SNI, fingerprint, port, update and uninstall operations plus the multi-protocol backend panel: +backend install/uninstall and status, a per-profile keys dialog (Hysteria 2 link + QR, AmneziaWG +client conf + QR), the subscription kill switch for `hysteria2://` lines and the AWG 3.1 toggle. +`probe-test`, HAPP setup, cascade, self-steal, the interactive menu and service diagnostics remain server-side operations. See [Electron Desktop GUI](desktop-gui.md) for the complete command mapping, security boundary, @@ -191,8 +225,10 @@ npm test npm run typecheck ``` +## Bypass routing -VPN. The `domain -> direct` rules sit above the catch-all, so they keep working with the cascade +Bypass routing sends selected domains around the tunnel: matched traffic goes `direct`, everything +else keeps using the VPN. The `domain -> direct` rules sit above the catch-all, so they keep working with the cascade enabled. Default bundle groups: @@ -209,6 +245,179 @@ Default bundle groups: The menu is interactive: arrows move the selection, space toggles a group, Enter applies. +## Multi-protocol backends + +Beyond Xray Reality, one VPS can run additional transports managed from the same CLI/menu. All +backend state lives in a neutral root that does not touch `/usr/local/etc/xray/`: + +```text +/usr/local/etc/xrayebator/ +├── backends.json # registry: per-backend installed/version/port/flags (644, no secrets) +└── backends/ + ├── hysteria2/ # server.yaml, certs, placeholder credential + └── awg/ # awg0.conf, server-params.json (0600) +``` + +Per-profile credentials are stored in the profile JSON itself (`.backends.hysteria2`, +`.backends.awg`) — the profile is the single source of truth, and backend server configs are always +regenerated from profiles. Profile lifecycle events (create/delete/revoke/expire/restore) propagate +to every installed backend automatically: revoking a profile rotates its Hysteria password and AWG +peer keys; expiring it removes the grant from both server configs; extending restores both. + +### Hysteria 2 (fast UDP) + +`hysteria2-install` downloads the binary from the official `HyNetworks/hysteria` releases (SHA-256 +verified), creates the `hysteria` service user and a dedicated systemd unit, and listens on UDP 443 +by default (QUIC next to the TCP 443 Reality inbound). TLS is adaptive: when the subscription +endpoint already has a Let's Encrypt certificate it is reused (clients connect with `insecure=0`), +otherwise a self-signed cert is generated (`insecure=1`). While no profile has a grant the auth map +carries a `_xrayebator_placeholder` user — Hysteria rejects an empty userpass map. Client links look +like `hysteria2://user:pass@host:port/?sni=…&insecure=0|1#name` and are appended to both +subscription bodies (HAPP and generic); the registry flag `sub_body` is the kill switch if a client +parser objects. + +### AmneziaWG 2.0 (system VPN) + +`awg-install` builds the kernel module via DKMS (primary path: the `amnezia/ppa` PPA; fallback: a +source build that needs the full `linux-source` tree on kernels ≥ 5.6) and brings up the `awg0` +interface through `awg-quick@awg0` with a random high UDP port, subnet `10.8.1.0/24`, `ip_forward` +and MASQUERADE on the default-route interface. Junk parameters mirror the Amnezia defaults — the +dialect every official AmneziaVPN client (phone and desktop) applies reliably: `Jc` 5, `Jmin` 10, +`Jmax` 50, `H1`–`H4` = 1..4 (the WireGuard magic headers), `S1`/`S2` random 12..150 and `S3`/`S4` +random 12..64 (all unique, `S1+56 ≠ S2`). Each profile peer gets a keypair, a preshared key and the +first free address; the +client `.conf` (menu item 12 or `awg-conf --name N`) carries full-tunnel `AllowedIPs` and the +server's junk parameters, and starts with a self-describing header noting that the profile runs +through AmneziaVPN/AmneziaWG, not V2Ray clients (HAPP). In the desktop GUI the same keys are +delivered per profile via the «Ключи» button in Server Settings: the Hysteria 2 link and the AWG +`.conf` as text with QR codes and one-click grant issuance. The «QR · AmneziaVPN» code carries the +config in the app-native `vpn://` shape (`amnezia-awg2` container, server-side junk fields, +`protocol_version`, compressed payload) — a raw `.conf` pasted into AmneziaVPN does not reach its +tunnel intact, so that code is the recommended delivery for this app; the plain QR stays for the +standalone AmneziaWG client. Peer changes restart the interface with +rollback — a brief tunnel blip for all peers, acceptable because grants and revokes are rare. + +### AWG 2.0 vs 3.x — and why 3.1 matters against DPI/ТСПУ + +Per the upstream protocol split, AmneziaWG parameters divide into two groups: + +| Must match byte-for-byte on server and client | Local per side | +|---|---| +| `S1`–`S4`, `H1`–`H4` | `PersistentKeepalive` (recommended 22–30) | +| 3.0: `HeaderProtectionKey` | 3.0: `ContentPaddingAddition`, `Rekey*`, `Reject*`, `Keepalive*`, `MaxHandshakeAttempts` (integer or `"a-b"` range) | +| 3.1: `RandomTrailers` | 3.1: `DisableCookies` | + +Xrayebator implements the AWG 3.1 feature set: junk parameters plus `S1`–`S4` (≥ 12), a randomly +generated `HeaderProtectionKey` shared between the server and every client `.conf`, and +`RandomTrailers = on`. `DisableCookies` is available but off by default (it trades away the +built-in anti-amplification defence). New installs get 3.1 immediately; for installs made before +the 3.1 stage, menu item 12 → «Режим AWG 3.1» or `awg-31 --on|--off` generates the missing keys, +switches the config format and regenerates the interface. The migration caveat is unchanged, and +it is exactly what matters against Russian DPI/ТСПУ analysis: plain WireGuard handshakes have +fixed packet lengths and plaintext header type bytes, so passive classification works without +inspecting payloads — AWG 3.0 encrypts the header, 3.1 removes the fixed-length signature. The +3.1 keys live in the must-match group, so after a switch **every previously issued client `.conf` +must be re-downloaded**, and clients need AmneziaVPN ≥ 5.0.1.5 — older apps refuse to import the +config at all. Verify by `awg show`: no `latest handshake` means the must-match group disagrees; a +handshake without traffic points to `awg-quick`/routing/firewall instead. + +### HAPP client routing profile + +The subscription endpoint returns a client-side routing profile to HAPP in the `routing:` response +header. The managed default, `xrayebator-default`, sets `GlobalProxy: "true"`, so every destination +except private IPv4 ranges is tunnelled. + +That is a different knob from [Bypass routing](#bypass-routing). Bypass changes where **the server** +sends a request; the client still tunnels it, so the destination sees the VPS address. On a node +without a cascade the catch-all outbound is already `direct`, so bypass cannot change what a Russian +site sees. Keeping domestic traffic out of the tunnel is only possible in the client profile. + +The profile travels to the client in a response header, so a large `DirectSites` list can outgrow +nginx's default 4k proxy buffer. The generated subscription vhost raises it (`proxy_buffer_size 32k`) +— in the domain-mode `location /sub/` and in the IP-mode (quickstart) vhost alike; if you run your own +reverse proxy in front of the subscription, set `proxy_buffer_size` there too, +otherwise nginx answers 502 and logs `upstream sent too big header`. + +### Generating it from the menu + +`HAPP subscription → 7) Маршрутизация клиента` writes a ready split profile built from the same +bundles the server-side bypass uses. It keeps `GlobalProxy: "true"`, so anything unknown still +tunnels, and moves the Russian bundles plus `geoip:ru` into `DirectSites` and `DirectIp`. The +previous override is backed up next to the file, and removing the override restores the managed +default. The profile uses placeholders (`{{GEOIP_URL}}`, `{{GEOSITE_URL}}`, `{{LAST_UPDATED}}`) +resolved per subscriber at request time — no token is embedded in the shared file. + +### Override file + +| Variable | Default | Meaning | +|---|---|---| +| `HAPP_ROUTING_ENABLED` | `true` | Set to `false` to stop sending the `routing:` header | +| `HAPP_ROUTING_JSON_FILE` | `/usr/local/etc/xray/.happ_routing.json` | Operator override | + +If the override file exists and passes HAPP schema validation, it replaces the managed default for +every subscriber. Malformed JSON is ignored in favour of the default rather than broadcast. Restart +`xrayebator-sub.service` is not required: the file is read per request. + +Verdicts are evaluated per destination. `GlobalProxy: "false"` makes `direct` the default and sends +only `ProxySites` and `ProxyIp` through the tunnel; `GlobalProxy: "true"` inverts that and treats +`DirectSites` and `DirectIp` as the exceptions. + +### Placeholders + +The override is read per request, so three values can be left for the server to fill in: + +| Placeholder | Replaced with | +|---|---| +| `{{GEOIP_URL}}` | `/sub//geoip.dat` for the requesting subscriber | +| `{{GEOSITE_URL}}` | `/sub//geosite.dat` for the requesting subscriber | +| `{{LAST_UPDATED}}` | newest mtime among `xrayebator` and the two geo databases | + +Substitution runs before schema validation, so an unresolved placeholder fails the `https://` check +and the managed default is served instead of a broken profile. `{{LAST_UPDATED}}` also removes the +need to bump the value by hand after editing the geo databases. + +Three things are easy to get wrong: + +- **Telegram travels to IP addresses, not domains.** Domain rules never match it. Take the ranges + from instead of writing them from memory; the list + changes, and a missing range silently degrades media downloads while chat still works. +- **`LastUpdated` must grow.** HAPP re-imports a profile only when the value is higher than the one + it already stored. +- **Geo databases must be reachable by the client.** The managed default points at + `/sub//geoip.dat`, which is per-subscriber. The placeholders above resolve this; without + them, host the databases somewhere every client can fetch them. +- **The country in the base URL can be wrong.** ASN geolocation differs between providers: ipinfo.io + and ipwho.is/ip-api may place the same range in different countries (e.g. NODE HOST LIMITED: + Finland vs Germany/Frankfurt). The managed HAPP profile sends the client to fetch geo databases + through `{{GEOIP_URL}}`/`{{GEOSITE_URL}}` of the same subscription, so under the http_tls fallback + (public subscription unavailable) a client without pre-cached databases cannot download them and + HAPP shows "no data". Fix by hand-writing `/usr/local/etc/xray/.server_country` + (`code|country|city`) and importing keys over SSH, or by restoring the public subscription. + +While a client downloads new geo databases the previous profile keeps running, so a failed download +leaves routing unchanged rather than broken. + +### One key — how many devices? + +VLESS, Hysteria 2 and AmneziaWG have different multi-device semantics: + +- **VLESS (subscription)** — the subscription hands out the profile's UUIDs, and UUIDs are not + exclusive: any number of devices can pull the same subscription in HAPP and connect + simultaneously, each opening its own Reality tunnels per route. The routes are transports, not + device slots. +- **Hysteria 2** — auth is per connection (`userpass`), and the server does not lock a credential + to one session: **one `hysteria2://` link works from unlimited devices simultaneously**, each + with its own QUIC session. They share the server's bandwidth, nothing else. +- **AmneziaWG** — cryptokey routing binds a peer to its keypair, and the interface keeps a single + endpoint per peer (last authenticated sender wins). **One key = one device reliably.** Two + devices on the same `.conf` fight over the endpoint and reconnect in turns; behind one NAT it + limps along, across networks it breaks. A second device needs its own profile/key — it gets its + own peer and `10.8.1.x` address. +- **nginx** serves only the subscription endpoint on 8443 (key delivery). Hysteria (UDP 443) and + AmneziaWG (their UDP port) bypass nginx entirely, and nothing load-balances between the + backends — they are independent services on separate ports; every device simply adds its own + session to whichever protocol it uses. + ## Cascade and upstream nodes The cascade is a server-side outbound and routing mode, not a new client profile. The client keeps diff --git a/docs/desktop-gui.md b/docs/desktop-gui.md index d2c2ee07..c50b9250 100644 --- a/docs/desktop-gui.md +++ b/docs/desktop-gui.md @@ -43,20 +43,22 @@ The wizard shows a step index and a live console of the work actually performed: ### Server keys -Server keys refreshes the subscription from the saved `subscription_url` and displays the returned VLESS routes. Each VLESS link can be copied or rendered as a QR code; the subscription URL can also be copied, and the page offers a copy-all action. This page does not create a separate server-side subscription or rotate a subscription token (rotation lives on the profile cards in Server settings). +Server keys refreshes the subscription from the saved `subscription_url` and displays the returned VLESS routes. Each VLESS link can be copied or rendered as a QR code; the subscription URL can also be copied, and the page offers a copy-all action. This page does not create a separate server-side subscription or rotate a subscription token (rotation lives on the profile cards in Server settings). When the server has multi-protocol backends, the page also shows their keys from the persistent local store: one Hysteria 2 card per granted profile with the `hysteria2://` link (copy + QR) — the card chip names the owning profile (`HYSTERIA2 · · UDP :`), and one AmneziaWG card per granted profile with the client `.conf` — the `Endpoint`/`Address` values are surfaced as key fields, and the full `.conf` (service comment header stripped) is available as text and QR for import into AmneziaVPN/AmneziaWG. Backend cards paint immediately from the persisted store and refresh in parallel with the VLESS routes; a per-card spinner shows only while that card's data is being fetched. The AWG card carries two QR codes: the plain one for the standalone AmneziaWG client and «QR · AmneziaVPN» — the app-native compressed `vpn://` shape (`amnezia-awg2` container, server-side junk fields, `protocol_version` 3.1), which is the recommended import path for the full AmneziaVPN app. ### Server settings Server settings first authenticates over SSH. When the card already has a keychain-backed SSH password or a persisted private key, the page attempts to connect automatically and then shows only the profile panel — the access form appears only when there is no saved secret or after a failed connection. The access summary and the "Change access" action live on the server card in the dashboard, not inside the profile page. Once connected, the page can: - list existing profiles; -- create one or more profiles and delete profiles; -- choose `xhttp`, `tcp`, `tcp-utls`, `tcp-xudp`, `tcp-mux`, or `grpc` transports; +- create one or more profiles and delete profiles (profile names are unique: the GUI pre-checks a name collision before creating — deletion is per-profile, so unique names keep it precise); +- choose `xhttp`, `tcp`, `tcp-utls`, `tcp-xudp`, `tcp-mux`, or `grpc` transports — or select one of the two multi-protocol backends (Hysteria 2 / AmneziaWG 3.1 cards below the transport grid). Clicking a backend card only selects the protocol (selected VLESS transports deselect it and vice versa); the profile is created by the «Create profile» button, and only the selected backend issues its key — the underlying VLESS profile uses the recommended `xhttp` transport; - change a profile fingerprint, choose an SNI from `sni-list`, or enter an SNI; - change a profile route's port or choose a random port; -- update the server installation; +- update the server installation (a branch menu: "As pinned on the server" — `.current_branch`, otherwise main; **main is the latest stable release**; **dev is the latest build with every feature but may be unstable**; switch back from dev by updating to main); - uninstall the server installation after confirmation; -- reset the pinned SSH host key after explicit confirmation. +- reset the pinned SSH host key after explicit confirmation; +- manage the multi-protocol backends (menu parity with items 11–13): the combined panel "additional protocols" (they are in development — the interface and behavior may change) shows the Hysteria 2 and AmneziaWG state (version, port, TLS mode / interface, junk summary) with install/uninstall, the subscription kill switch for `hysteria2://` lines and the AWG 3.1 toggle — the last two with explicit confirmation; +- per-profile backend boxes in the profiles list, titled by the profile name with the protocol in the subtitle and a green status line (`Active · SNI …` or `Active · UDP `): QR (plus «QR · AmneziaVPN» for AmneziaWG), Expiry, Revoke and Delete. Deleting a profile revokes its backend keys immediately — the AWG peer is removed from `awg0.conf` and the Hysteria password from `server.yaml` (no orphaned credentials) — and the name becomes free for reuse. Full key copying lives on the Server keys page. SNI and port are inbound-level settings: changing them can affect every profile sharing that inbound. Fingerprint is different: it is a client-side value stored per profile/route and does not change the other routes. The server command reports the result and whether reconnecting is required. @@ -91,6 +93,14 @@ xrayebator port-change --name NAME [--route R] --port PORT|random xrayebator bypass list|add --domain D|remove --domain D|reset|bundle [--group a,b,c] ``` +Backend management additionally invokes: + +```text +xrayebator backend-status +xrayebator hysteria2-install [--port P] [--grant-all] / hysteria2-uninstall / hysteria2-grant --name N / hysteria2-subbody --on|--off / hysteria2-link --name N +xrayebator awg-install [--grant-all] / awg-uninstall / awg-grant --name N / awg-conf --name N / awg-31 --on|--off +``` + Deployment additionally invokes: ```text diff --git a/docs/releases/v0.6.5.en.md b/docs/releases/v0.6.5.en.md new file mode 100644 index 00000000..042e308f --- /dev/null +++ b/docs/releases/v0.6.5.en.md @@ -0,0 +1,172 @@ +# Xrayebator v0.6.5 — multi-protocol: Hysteria 2 and AmneziaWG 3.1 join Reality + +> ⚠️ **This is a beta.** `0.6.5` is in testing and may be unstable. If you need a proven build, stay on `0.6.0`. Found a problem, have logs or errors — post them as comments on this release (or separate issues): logs are the fastest way for us to fix things. + +> An update to the server manager and the **Xrayebator Desktop GUI**. Installs over `0.6.0` — migrations do the work, no need to reconfigure existing servers. + +## In two words + +Until now the server could run a single protocol — Xray Reality over TCP. When censorship throttles TCP, the whole VPN falls at once: one transport, one point of failure. + +`v0.6.5` puts two more transports on the same server and teaches the panel to manage them: + +1. **Hysteria 2** — a UDP/QUIC transport: fast on bad links, alive where TCP is cut; +2. **AmneziaWG 3.1** — a system VPN (WireGuard with anti-DPI junk in the headers): full tunnel, native AmneziaVPN apps; +3. **Per-profile grants** — each protocol's key is issued separately and lives in one lifecycle with the profile: revoke, expiry and deletion revoke the backend keys together with VLESS. + +```text +┌────────────────────────────────────────────────────────┐ +│ one VPS │ +│ │ +│ Xray (Reality, TCP) ── ports 443 / high 30xxx │ +│ Hysteria 2 (QUIC, UDP) ── its own port │ +│ AmneziaWG 3.1 (UDP) ── awg0, its own port │ +│ │ +│ subscription: vless:// + hysteria2:// + HAPP routes │ +└────────────────────────────────────────────────────────┘ + ▲ ▲ ▲ + │ │ │ + HAPP / v2rayNG HAPP / Hiddify AmneziaVPN + ( Reality+Hysteria ) ( Reality+Hysteria ) ( AmneziaWG 3.1 ) +``` + +The client subscription stays a single one: `hysteria2://` lines are appended to the same subscription bodies (with a kill switch in the GUI), the AWG config is delivered as a `.conf` file or QR, and for the full AmneziaVPN app — in the native `vpn://` format. + +## What is new + +### 1. A neutral backend registry + +The state of the extra protocols lives in the neutral root `/usr/local/etc/xrayebator/` (the `backends.json` registry is 644 with no secrets, runtime state with secrets is 750). The `/usr/local/etc/xray/` directory stays Xray-only. One choke point — `_backend_apply_profile_lifecycle` — runs every profile lifecycle event through all installed backends, so revoke/expiry/deletion never leaves orphaned credentials. + +### 2. Hysteria 2 (UDP/QUIC) + +- installed from official `HyNetworks/hysteria` releases with sha256 verification, a dedicated `hysteria` system user and its own `hysteria-server.service` (CAP_NET_BIND_SERVICE, auto-restart), QUIC buffer sysctls; +- adaptive TLS: when the subscription has a Let's Encrypt certificate it is copied into the backend (with a renewal deploy-hook), otherwise a self-signed cert is generated — and client links honestly carry `insecure=1`; +- per-profile keys: the password lives in the profile's `.backends.hysteria2`, `server.yaml` is **always regenerated from profiles** — revoking or extending a key is just a regen plus a restart with rollback; +- `hysteria2://` is appended to both subscription bodies (HAPP and v2rayNG), managed by a kill switch without a service restart. + +### 3. AmneziaWG 3.1 (system VPN) + +- kernel module via DKMS: the primary path is the `amnezia/ppa` PPA, the fallback is a source build (kernels ≥ 5.6 need the full `linux-source` tree); +- junk parameters match the official Amnezia dialect exactly (Jc=5, Jmin/Jmax, H1–H4, S1–S4): the official apps apply this set predictably; +- the **3.1 format is on by default**: `HeaderProtectionKey`, `RandomTrailers`, `S3`/`S4`; the `awg-31` toggle exists with an explicit warning — issued client `.conf` files must be re-downloaded (AmneziaVPN ≥ 5.0.1.5); +- per-profile peers: client keypair + preshared key, addresses from `10.8.1.0/24`; `awg0.conf` is regenerated from profiles; +- the client `.conf` is a full tunnel (`AllowedIPs = 0.0.0.0/0, ::/0`), keepalive 25; in the GUI the key can be taken as `.conf`, QR or "QR · AmneziaVPN" — the native `vpn://` format the app imports fully (a raw `.conf` pasted into the full AmneziaVPN does not reach its tunnel intact). + +### 4. Lifecycle: keys do not live their own life + +- **Profile creation** does NOT auto-issue a key: the operator picks the protocol in the GUI (or runs an explicit `hysteria2-grant` / `awg-grant`), otherwise every new profile would immediately receive keys from all installed backends; +- **Revoke** rotates the Hysteria password and the AWG peer's keypair+PSK (old configs drop, the address is kept); +- **Expiry/renewal** is a regen from profiles with disabled ones filtered out for backends: an expired profile stops receiving keys, a renewed one comes back; +- **Profile deletion** removes the peer from `awg0.conf` and the password from `server.yaml` in the same command; there are no orphaned credentials. + +### 5. GUI: the "additional protocols" panel + +- a combined backends block in Server settings: official logos, state (version, port, TLS mode / interface, junk summary), install/uninstall; +- backend key cards per profile — on equal footing with VLESS cards: QR (including "QR · AmneziaVPN"), expiry with presets, revoke, delete; confirmations use the soft danger style; +- profile creation picks the protocol: plain VLESS, Hysteria 2 or AmneziaWG — only the chosen protocol issues a key; +- the Keys page shows hysteria2 lines from the subscription and AWG configs alongside the VLESS links. + +### 6. HAPP routing: split routing from bypass groups + +The managed HAPP profile can now be assembled from bypass groups (`bypass list/add/remove/reset/bundle`): domains of the chosen groups go direct, the rest goes into the tunnel. The client downloads geo databases through the subscription itself (the `{{GEOIP_URL}}` / `{{GEOSITE_URL}}` / `{{LAST_UPDATED}}` placeholders are resolved per subscriber — the database URL is bound to their token). + +### 7. quickstart degrades to `http_tls` instead of dying + +Some hosters filter inbound port 80 for foreign sources — Let's Encrypt cannot validate http-01 for a bare IP (`Connection reset by peer` from validation hosts). This used to be a dead end; now: + +- the deploy finishes with `degraded:true`, `tls_mode:"http_tls"` and the Certbot reason; +- the subscription stays **loopback-only — there is no public URL at all**: the GUI loads keys over SSH from `http://127.0.0.1:8080/sub/` on the server itself (nothing to leak); +- a self-signed certificate is prepared in advance — for future Hysteria installs; +- the ACME location on :80 stays in place: once the hoster unblocks the port, a re-run of quickstart (idempotent) issues the LE certificate and restores HTTPS. + +In the GUI such a server is stored as "Partially configured" with a degraded badge. + +### 8. SNI and country + +- the SNI candidate list is updated from the 03.09.2026 reachability measurements; the default SNI for new profiles is `www.cloudflare.com` (the most stable); +- subscription keys are labelled with the server country: fragments look like `🇫🇮 Finland · happ-*` — handy when you run several servers. + +## What else landed in the branch + +- `revoke --full` crashed on single-route profiles (an invalid jq path) — fixed; +- `profile-delete` printed colored status lines into the stdout JSON — the CLI path is JSON-clean now; +- AWG ports moved out of the ephemeral range; ufw-reload is not churned during batch firewall edits; +- dead duplicate function definitions removed from the single-file manager (one of them would have clobbered the subscription markers if ever called — it was only reachable by static analysis); +- a wave of GUI feedback polish: backend logos, status lines on cards, the inverted AWG 3.1 toggle fixed, the "Keys" button removed from cards (copying lives on the Keys page only), danger-soft revoke confirmations; +- documentation synchronized in three languages (ru/en/zh): the CLI reference, GUI, troubleshooting (including the new `certbot failed ... Connection reset by peer` section), testing (34 scripts), architecture, security. + +## How thoroughly it is tested + +Automatically: + +- Bash validation: **34 scripts** (up from 28) — new: the backend registry, Hysteria 2 lifecycle, AmneziaWG junk/renderers/lifecycle, HAPP client routing, legacy profile port sync, the `http_tls` fallback; the revoke/expire CLI and subscription statics were extended; +- GUI: Vitest tests for the AWG export (`vpn://`, range timers, qCompress), backend-manager, inspector; typecheck and build are clean; +- CI on ubuntu-24.04 runs all 34 scripts; bundles build on three OSes. + +Live on VPSes (multi-protocol spikes): installing both backends from the menu and CLI, issuing keys, `insecure=1` under a self-signed cert, importing `vpn://` into AmneziaVPN ≥ 5.0.1.5, quickstart degradation on a VPS with :80 filtering (keys over SSH, backends on the self-signed cert). Some checks are inherently live — DKMS network builds and real client connections cannot be reproduced in CI, honestly noted in "Limitations". + +## Field notes + +- **Junk is a dialect, not a number.** The official AmneziaVPN apps apply the Amnezia defaults predictably; their go-tunnel applies custom junk fields incompletely. That is why the generator emits exactly the dialect the official clients understand. +- **A hoster's :80 filter is only visible from outside.** On the VPS itself nginx and the firewall are fine, while certbot gets resets from validation hosts. Diagnosis: tcpdump on :80 plus multi-node probes from different countries. Fix: a ticket to the hoster or the domain TLS mode. +- **Geo databases disagree per ASN.** ipinfo.io and ipwho.is/ip-api can place the same range in different countries (example: NODE HOST LIMITED — Finland vs Germany/Frankfurt). The server country is written to a marker and into key names; on disagreement it is corrected manually. +- **The http_tls fallback cannot serve HAPP geo databases.** The managed profile sends the client to fetch `geoip.dat`/`geosite.dat` through the subscription itself; without a public subscription a client without pre-loaded bases shows "no data". Keys keep working — routing stays default. +- **An `awg0` restart drops the tunnel for all peers for seconds.** Grants/revokes are rare; for stage 1 this is accepted: regen + restart is simpler and more reliable than incremental edits. + +## Before you update + +### Server requirements + +- Debian 12/13 or Ubuntu 22.04/24.04, systemd, bash, root/sudo; +- for AmneziaWG — kernel ≥ 5.6 and a working DKMS build (PPA or sources); +- migrations catch the state up automatically at the first menu run or quickstart. + +### Breaking changes + +For existing VLESS-only installations there are none: everything is additive, profiles and subscriptions stay compatible. Worth knowing: + +- **one AWG key = one device** — two devices on one `.conf` will fight over the endpoint; +- **Hysteria under a self-signed cert works with `insecure=1`** — a deliberate mode until the LE certificate is issued; +- **an `http_tls` server has no public subscription** — keys are fetched over SSH from the GUI (or via curl over SSH on the server). + +### Honest limitations + +- HAPP's acceptance of `hysteria2://` lines in the subscription bodies and real AWG client connections were verified in a live spike, not CI (network phases cannot be reproduced in CI); +- an `awg0` restart on grant/revoke briefly drops the tunnel for all peers; +- HAPP geo databases are unavailable to the client without pre-loading under the `http_tls` fallback ("no data" is not broken keys); +- the post-quantum PQ route still is not placed into the subscription (the URL is ~2 KB) — that is size, not a bug. + +## How to update + +### Server + +```bash +sudo xrayebator update main +``` + +Backends are **not installed automatically**: after the update open Server settings → "Multi-protocol backends" and install Hysteria 2 and/or AmneziaWG with the button. Keys are issued per profile — creation goes through the protocol choice. + +### Xrayebator Desktop GUI + +1. Download the package for your OS from the GitHub Release. +2. Install over the previous version — server cards are preserved. +3. New: the backends panel in Server settings, backend cards on profiles, Hysteria/AWG keys on the Keys page. +4. The "Update Xrayebator" button is now a branch menu: "Auto — as pinned on the server" (the pinned `.current_branch`, otherwise main), "main — latest stable release", "dev — every feature first" (may be unstable — this is how this beta reaches a server before the release). + +## Build + +The release is built natively for three systems: + +- Windows — `Xrayebator-0.6.5-win.exe`; +- macOS Apple Silicon — `Xrayebator-0.6.5-mac-arm64.dmg` and `.zip` (the ZIP is needed by the built-in auto-update); +- Linux — `Xrayebator-0.6.5-linux.AppImage`. + +Before the build, typecheck, unit tests and the full Bash validation suite (34 scripts) run on Ubuntu. + +## Links + +- [Main README](https://github.com/howdeploy/Xrayebator/blob/main/README.md) +- [Electron Desktop GUI reference](https://github.com/howdeploy/Xrayebator/blob/main/docs/desktop-gui.md) +- [Testing](https://github.com/howdeploy/Xrayebator/blob/main/docs/testing.md) +- [Canonical repository](https://github.com/howdeploy/Xrayebator) diff --git a/docs/releases/v0.6.5.md b/docs/releases/v0.6.5.md new file mode 100644 index 00000000..4c49eeb3 --- /dev/null +++ b/docs/releases/v0.6.5.md @@ -0,0 +1,172 @@ +# Xrayebator v0.6.5 — мультипротокол: к Reality добавились Hysteria 2 и AmneziaWG 3.1 + +> ⚠️ **Это бета-версия.** `0.6.5` находится в тестировании и может быть нестабильной. Если нужен проверенный вариант — ставьте `0.6.0`. Нашли проблему, есть логи или ошибки — пишите их комментариями к этому релизу (или отдельным issue): по логам чиним быстрее всего. + +> Обновление серверного менеджера и **Xrayebator Desktop GUI**. Ставится поверх `0.6.0` — миграции доделают всё сами, перенастраивать серверы не нужно. + +## Если объяснить в двух словах + +До сих пор сервер умел один протокол — Xray Reality поверх TCP. Цензура душит TCP — и весь VPN падает целиком: один транспорт, одна точка отказа. + +`v0.6.5` ставит на тот же сервер ещё два транспорта, а панель — управлять ими: + +1. **Hysteria 2** — UDP/QUIC-транспорт: быстрый на плохих каналах, живёт там, где TCP режут; +2. **AmneziaWG 3.1** — системный VPN (WireGuard с анти-DPI «мусором» в заголовках): полный туннель, нативные приложения AmneziaVPN; +3. **Гранты по профилю** — ключ каждого протокола выдаётся отдельно и живёт в одном жизненном цикле с профилем: revoke, срок, удаление отзывают бэкенд-ключи вместе с VLESS. + +```text +┌────────────────────────────────────────────────────────┐ +│ один VPS │ +│ │ +│ Xray (Reality, TCP) ── порты 443 / высокие 30xxx │ +│ Hysteria 2 (QUIC, UDP) ── свой порт │ +│ AmneziaWG 3.1 (UDP) ── awg0, свой порт │ +│ │ +│ подписка: vless:// + hysteria2:// + маршруты HAPP │ +└────────────────────────────────────────────────────────┘ + ▲ ▲ ▲ + │ │ │ + HAPP / v2rayNG HAPP / Hiddify AmneziaVPN + ( Reality+Hysteria ) ( Reality+Hysteria ) ( AmneziaWG 3.1 ) +``` + +Клиентская подписка остаётся одной: строки `hysteria2://` добавляются в те же тела подписки (с килл-свитчем в GUI), AWG-конфиг выдаётся как файл `.conf` или QR, а для полного AmneziaVPN — в родном формате `vpn://`. + +## Что нового + +### 1. Нейтральный реестр бэкендов + +Состояние допротоколов живёт в нейтральном корне `/usr/local/etc/xrayebator/` (реестр `backends.json` — 644 без секретов, runtime-состояние с секретами — 750). Каталог `/usr/local/etc/xray/` остаётся только про Xray. Один choke point — `_backend_apply_profile_lifecycle` — проводит каждый жизненный цикл профиля через все установленные бэкенды, так что revoke/срок/удаление не оставляют осиротевших креденшелов. + +### 2. Hysteria 2 (UDP/QUIC) + +- установка из официальных релизов `HyNetworks/hysteria` с проверкой sha256, отдельный системный пользователь `hysteria` и свой `hysteria-server.service` (CAP_NET_BIND_SERVICE, авто-рестарт), QUIC-буферы sysctl; +- TLS адаптивный: если у подписки есть Let's Encrypt-сертификат — он копируется в бэкенд (с deploy-hook'ом на продление), иначе генерируется self-signed — и клиентские ссылки честно получают `insecure=1`; +- ключи per-profile: пароль лежит в `.backends.hysteria2` профиля, `server.yaml` **всегда регенерируется из профилей** — отозвать или продлить ключ это просто реген + рестарт с откатом; +- `hysteria2://` добавляется в оба тела подписки (HAPP и v2rayNG), управляется килл-свитчем без рестарта сервиса. + +### 3. AmneziaWG 3.1 (системный VPN) + +- kernel-модуль через DKMS: основной путь — PPA `amnezia/ppa`, фолбэк — сборка из исходников (для ядра ≥5.6 нужен полный `linux-source`); +- junk-параметры — один в один с официальным Amnezia-диалектом (Jc=5, Jmin/Jmax, H1–H4, S1–S4): их родные приложения применяют предсказуемо; +- формат **3.1 включён по умолчанию**: `HeaderProtectionKey`, `RandomTrailers`, `S3`/`S4`; тумблер `awg-31` существует с явным предупреждением — старые клиентские `.conf` нужно скачать заново (AmneziaVPN ≥ 5.0.1.5); +- peer'ы per-profile: клиентская пара ключей + preshared key, адреса из `10.8.1.0/24`; `awg0.conf` регенерируется из профилей; +- клиентский `.conf` — полный туннель (`AllowedIPs = 0.0.0.0/0, ::/0`), keepalive 25; в GUI ключ можно забрать `.conf`, QR или «QR · AmneziaVPN» — родной `vpn://`-формат, который приложение импортирует целиком (обычный `.conf`, вставленный в полное AmneziaVPN, до туннеля доходит не intact). + +### 4. Жизненный цикл: ключи не живут своей жизнью + +- **Создание профиля** — ключ НЕ выдаётся автоматически: оператор выбирает протокол в GUI (или даёт явный `hysteria2-grant` / `awg-grant`), иначе каждому новому профилю приходили бы ключи ото всех установленных бэкендов сразу; +- **Revoke** — ротация пароля Hysteria и пары ключей+PSK peer'а AWG (старые конфиги отваливаются, адрес сохраняется); +- **Срок/продление** — для бэкендов это реген из профилей с фильтром отключённых: истёкший профиль перестаёт получать ключ, продлённый возвращается; +- **Удаление профиля** — peer снимается с `awg0.conf`, пароль уходит из `server.yaml` в той же команде; осиротевших креденшелов нет. + +### 5. GUI: панель «дополнительные протоколы» + +- общий блок бэкендов в «Настройках сервера»: официальные логотипы, состояние (версия, порт, TLS-режим / интерфейс, сводка junk), установка/удаление; +- карточки бэкенд-ключей у профиля — на равных с VLESS: QR (включая «QR · AmneziaVPN»), срок с пресетами, Revoke, удаление; подтверждения — мягкий danger-soft; +- при создании профиля выбирается протокол: обычный VLESS или Hysteria 2 / AmneziaWG — ключ получит только выбранный протокол; +- страница «Ключи» показывает hysteria2-строки из подписки и AWG-конфиги наравне с VLESS-ссылками. + +### 6. HAPP-маршрутизация: split routing из bypass-групп + +Управляемый HAPP-профиль теперь умеет собираться из bypass-наборов (`bypass list/add/remove/reset/bundle`): домены выбранных групп уходят direct, остальное — в туннель. Geo-базы клиент скачивает через саму подписку (плейсхолдеры `{{GEOIP_URL}}` / `{{GEOSITE_URL}}` / `{{LAST_UPDATED}}` подставляются на каждого подписчика — адрес базы привязан к его токену). + +### 7. quickstart деградирует в `http_tls`, а не умирает + +Часть хостеров фильтрует входящий порт 80 для зарубежных источников — Let's Encrypt не может проверить http-01 для голого IP (`Connection reset by peer` от validation-хостов). Раньше это был dead-end, теперь: + +- деплой завершается с `degraded:true`, `tls_mode:"http_tls"` и причиной certbot; +- подписка остаётся **только на loopback — публичного URL нет вовсе**: ключи GUI загружает по SSH с `http://127.0.0.1:8080/sub/` на самом сервере (утекать нечему); +- self-signed-сертификат готовится заранее — для будущих Hysteria-инсталлов; +- ACME-location на :80 сохраняется: хостер разблокировал порт → повторный quickstart (идемпотентный) выпускает LE-серт и возвращает HTTPS. + +В GUI такой сервер хранится как «Настроен частично» с пометкой деградации. + +### 8. SNI и страна + +- список SNI-кандидатов обновлён по замерам достижимости 03.09.2026; дефолт новых профилей — `www.cloudflare.com` (самый стабильный); +- ключи подписки подписаны страной сервера: фрагменты выглядят как `🇫🇮 Finland · happ-*` — удобно, когда серверов несколько. + +## Что ещё попало в ветку + +- `revoke --full` падал на single-route профилях (невалидный jq path) — исправлено; +- `profile-delete` печатал цветные статусы в stdout JSON — CLI-путь теперь JSON-чистый; +- AWG-порты вынесены из ephemeral-диапазона; ufw-reload не дёргается при пакетных правках firewall; +- из single-file менеджера убраны мёртвые дубли функций (один из них при вызове затёр бы маркеры подписки — до этого добирался только статический анализ); +- GUI-волна по фидбеку: логотипы бэкендов, статусные строки на карточках, инверсия тумблера AWG 3.1 исправлена, кнопки «Ключи» на карточках убраны (копирование — только на странице ключей), Revoke-подтверждения в danger-soft; +- документация синхронизирована на трёх языках (ru/en/zh): CLI-справочник, GUI, troubleshooting (включая новый раздел про `certbot failed ... Connection reset by peer`), testing (34 скрипта), architecture, security. + +## Насколько это проверено + +Автоматически: + +- Bash-валидация: **34 скрипта** (было 28) — новые: реестр бэкендов, жизненный цикл Hysteria 2, junk/рендереры и жизненный цикл AmneziaWG, HAPP client routing, синхронизация портов легаси-профилей, `http_tls`-fallback; расширены revoke/expire-CLI и статика подписки; +- GUI: Vitest-тесты AWG-экспорта (`vpn://`, range-таймеры, qCompress), backend-manager, инспектор; typecheck и сборка чистые; +- CI на ubuntu-24.04 прогоняет все 34 скрипта; сборки — на трёх ОС. + +Живо на VPS (мультипротокольные спайки): установка обоих бэкендов из меню и CLI, выдача ключей, `insecure=1` под self-signed, импорт `vpn://` в AmneziaVPN ≥ 5.0.1.5, деградация quickstart на VPS с фильтрацией :80 (подписка по SSH, бэкенды на self-signed серте). Часть проверок принципиально живая — сетевые сборки DKMS и реальное подключение клиентов в CI не воспроизвести, это честно отмечено в «Ограничениях». + +## Полевые заметки + +- **Мусор (junk) — это диалект, а не число.** Родные приложения AmneziaVPN применяют набор Amnezia-дефолтов предсказуемо; кастомные junk-поля их go-туннель применяет неполностью. Поэтому генератор выдаёт ровно тот диалект, который понимают официальные клиенты. +- **Фильтрация :80 у хостера видна только снаружи.** На самом VPS nginx и firewall исправны, а `certbot` получает reset от validation-хостов. Диагностика: tcpdump на :80 + мультиузловые пробы с разных стран. Лечение — тикет хостеру или доменный TLS-режим. +- **Geo-базы расходятся по ASN.** ipinfo.io и ipwho.is/ip-api могут разнести один и тот же диапазон по разным странам (пример: NODE HOST LIMITED — Финляндия против Germany/Frankfurt). Страна сервера пишется в маркер и в имена ключей; при расхождении правится вручную. +- **http_tls-фолбэк не даёт HAPP geo-баз.** Управляемый профиль шлёт клиента за `geoip.dat`/`geosite.dat` через саму подписку; без публичной подписки клиент без предзагруженных баз покажет «no data». Ключи при этом работают — маршрутизация остаётся дефолтной. +- **Рестарт `awg0` роняет туннель на секунды всем peer'ам.** Гранты/отзывы редки, для stage 1 это принято: реген + рестарт проще и надёжнее инкрементальных правок. + +## Важно перед обновлением + +### Требования к серверу + +- Debian 12/13 или Ubuntu 22.04/24.04, systemd, bash, root/sudo; +- для AmneziaWG — ядро ≥ 5.6 и рабочая DKMS-сборка (PPA или исходники); +- миграции догонят состояние автоматически при первом запуске меню или quickstart. + +### Breaking changes + +Для существующих VLESS-установок их нет: всё аддитивно, профили и подписка совместимы. Знать стоит: + +- **один AWG-ключ = одно устройство** — два устройства на одном `.conf` будут воевать за endpoint; +- **Hysteria под self-signed работает с `insecure=1`** — это осознанный режим до выпуска LE-сертификата; +- **`http_tls`-сервер не имеет публичной подписки** — ключи забираются по SSH из GUI (или curl'ом по SSH на сервере). + +### Честные ограничения + +- приём `hysteria2://`-строк в телах подписки HAPP и реальное подключение AWG-клиентов проверены живым спайком, не CI (сетевые фазы в CI не воспроизводятся); +- рестарт `awg0` при гранте/отзыве кратко роняет туннель всем peer'ам; +- geo-базы HAPP при `http_tls`-фолбэке недоступны клиенту без предзагрузки («no data» — не поломка ключей); +- пост-квантовый PQ-маршрут по-прежнему не кладётся в подписку (URL ~2 КБ) — это размер, а не баг. + +## Как обновиться + +### Сервер + +```bash +sudo xrayebator update main +``` + +Бэкенды **не ставятся сами**: после обновления откройте «Настройки сервера» → «Мультипротокольные бэкенды» и установите Hysteria 2 и/или AmneziaWG кнопкой. Ключи выдаются по профилям — создание через выбор протокола. + +### Xrayebator Desktop GUI + +1. Скачайте пакет для своей ОС из GitHub Release. +2. Установите поверх предыдущей версии — карточки серверов сохранятся. +3. Новое: панель бэкендов в «Настройках сервера», бэкенд-карточки у профилей, ключи Hysteria/AWG на странице «Ключи». +4. Кнопка «Обновить Xrayebator» теперь меню веток: «Авто — как закреплено на сервере» (прежде закреплённая `.current_branch`, иначе main), «main — последняя стабильная версия», «dev — все фишки сразу» (может быть нестабильной — именно так на сервер ставится эта бета до релиза). + +## Сборка + +Релиз собирается нативно для трёх систем: + +- Windows — `Xrayebator-0.6.5-win.exe`; +- macOS Apple Silicon — `Xrayebator-0.6.5-mac-arm64.dmg` и `.zip` (ZIP нужен встроенному автообновлению); +- Linux — `Xrayebator-0.6.5-linux.AppImage`. + +Перед сборкой на Ubuntu прогоняются typecheck, unit-тесты и полный набор Bash-валидаций (34 скрипта). + +## Ссылки + +- [Основной README](https://github.com/howdeploy/Xrayebator/blob/main/README.ru.md) +- [Справочник Electron Desktop GUI](https://github.com/howdeploy/Xrayebator/blob/main/docs/ru/desktop-gui.md) +- [Тестирование](https://github.com/howdeploy/Xrayebator/blob/main/docs/ru/testing.md) +- [Канонический репозиторий](https://github.com/howdeploy/Xrayebator) diff --git a/docs/ru/architecture.md b/docs/ru/architecture.md index 3f9d78ce..ae7fd8c7 100644 --- a/docs/ru/architecture.md +++ b/docs/ru/architecture.md @@ -52,7 +52,7 @@ Bash-управлению, а не вторая реализация серве /usr/local/etc/xray/ ├── config.json # инбаунды, outbounds, routing и DNS -├── profiles/.json # метаданные профиля, токен подписки, срок действия (.expire) +├── profiles/.json # метаданные профиля, токен подписки, срок действия (.expire), гранты бэкендов (.backends) ├── upstreams/cascade.json # параметры upstream каскада ├── backups/ # бэкапы конфига перед runtime-мутациями ├── .private_key / .public_key # ключи Reality @@ -62,10 +62,18 @@ Bash-управлению, а не вторая реализация серве ├── .current_branch # ветка менеджера для lifecycle-обновлений └── маркеры миграций # записи выполненных одноразовых миграций +/usr/local/etc/xrayebator/ # корень мультипротокольных бэкендов (нейтральный, пути Xray не трогает) +├── backends.json # реестр бэкендов: installed/version/port/флаги (644, без секретов) +└── backends/ + ├── hysteria2/ # server.yaml (регенерируется из профилей), сертификаты + └── awg/ # awg0.conf (0600), server-params.json (0600) + /usr/local/share/xray/ # geoip.dat и geosite.dat /var/log/xray/ # каталог логов, куда пишет сервис Xray /etc/systemd/system/xray.service.d/security.conf /etc/systemd/system/xrayebator-sub.service +/etc/systemd/system/hysteria-server.service # бэкенд Hysteria 2 (опционально) +/etc/systemd/system/awg-quick@awg0.service # бэкенд AmneziaWG (опционально, из amneziawg-tools) /etc/nginx/sites-available/xrayebator-sub ``` @@ -98,12 +106,17 @@ Fingerprint Reality — клиентское значение, хранящее ```text https:///sub/<32-hex-token> # public TLS на 443 https://:8443/sub/<32-hex-token> # public TLS на другом порту +http://:8080/sub/ # http_tls-fallback (LE не смог проверить http-01) http://127.0.0.1:8080/sub/ # local-only запасной ``` Интерактивная настройка HAPP может выбрать публичный порт, а `_subscription_base_url` сохраняет этот выбор. Нон-интерактивные IP-TLS пути `quickstart --email
` и `quickstart --without-email` создают nginx, сертификат и маркеры на `8443`, затем возвращают JSON с `subscription_url` для endpoint. +Если Let's Encrypt не может проверить http-01 (хостер фильтрует порт 80 для зарубежных источников), +quickstart деградирует в `http_tls`: маркеры получают этот режим, подписка публикуется по HTTP с +loopback-хендлера, а JSON несёт `degraded:true`; повторный деплой после разблокировки :80 возвращает +ip_tls. Без email Certbot регистрирует ACME-аккаунт без контактного адреса: уведомления о продлении и восстановление по email недоступны. Токен хранится в профиле как `sub_token`; revoke меняет его и аннулирует старый URL, а полный отзыв (`profile-revoke --full`) дополнительно меняет uuid во всех inbound'ах профиля, поэтому @@ -124,7 +137,32 @@ PQ-маршрут остаётся доступен через raw/profile path. Handler также отдаёт защищённые токеном `geoip.dat` и `geosite.dat`, необходимые для управляемого HAPP routing profile. HAPP получает routing metadata, а v2rayNG и v2rayN — совместимое VLESS-тело -без HAPP-only метаданных. +без HAPP-only метаданных. Когда бэкенд Hysteria 2 установлен и у профиля есть грант, handler +дополнительно добавляет одну строку `hysteria2://` в оба тела подписки (HAPP и generic) — под +управлением флага `sub_body` в реестре; истёкший или отключённый профиль строки не получает. + +## Мультипротокольные бэкенды + +Помимо Xray, менеджер может установить на тот же VPS дополнительные транспорты. Всё состояние +бэкендов живёт в нейтральном корне `/usr/local/etc/xrayebator/` (реестр `backends.json` + каталоги +по бэкендам); пути Xray не затрагиваются. Клиентские креденшелы бэкендов хранятся в самом профиле +(аддитивный объект `.backends`) — профиль является единственным источником правды: серверные +конфиги (`backends/hysteria2/server.yaml`, `backends/awg/awg0.conf`) всегда регенерируются из +профилей, а события жизненного цикла профиля (создание, удаление, revoke, истечение, продление) +проходят через `_backend_apply_profile_lifecycle` в каждый установленный бэкенд. + +- **Hysteria 2** — быстрый UDP-транспорт. Собственный systemd-юнит `hysteria-server.service` от + выделенного пользователя `hysteria` с `CAP_NET_BIND_SERVICE`; UDP 443 по умолчанию; адаптивный + TLS (сертификат подписки Let's Encrypt + deploy-hook на продление, иначе self-signed); + `auth: userpass` — один credential на профиль и placeholder-пользователь, пока карта пуста + (Hysteria отвергает пустую карту). +- **AmneziaWG 2.0** — системный VPN. Kernel-модуль через DKMS (основной путь — PPA `amnezia/ppa`, + фолбэк — сборка из исходников), интерфейс `awg0` через `awg-quick@awg0`, подсеть `10.8.1.0/24`, + peer-ы на профиль (ключевая пара + preshared key + адрес), `ip_forward` и MASQUERADE на + интерфейс default-route. Дистрибутивный юнит читает `/etc/amnezia/amneziawg/awg0.conf` — это + symlink на управляемый файл. +- Оба бэкенда опциональны: ставятся и снимаются из меню (пункты `11`–`13`) или CLI-командами + `hysteria2-*` / `awg-*` и никогда не устанавливаются по умолчанию. ## Пути обновления diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md index 589dab4d..dd73bd8d 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -95,12 +95,16 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `4` | Управление профилем: SNI, клиентский fingerprint, порт и продвинутые настройки | | `5` | Обновить отдельный профиль до post-quantum XHTTP + Reality | | `6` | Подписка HAPP: настройка публичного/локального publishing, URL/QR, revoke и HAPP-настройки; managed профиль имеет 7 маршрутов, публикуемый список — 6 | +| `7` | Bypass routing: выбранные домены (банки, маркетплейсы, Steam…) идут напрямую, минуя туннель | | `8` | Каскад и upstream-ноды | | `9` | Собственный домен и self-steal заглушка | | `10` | Поднять outbound-сервер, чтобы другой VPS мог использовать этот как зарубежную ноду каскада | +| `11` | Бэкенд Hysteria 2: установка/статус/ссылка клиента/удаление (быстрый UDP-транспорт) | +| `12` | Бэкенд AmneziaWG 2.0: установка/статус/конфиг клиента/удаление (системный VPN) | +| `13` | Статус всех мультипротокольных бэкендов | | `0` | Выход | -Действия пронумерованы подряд от `1` до `10`; `0` завершает программу. SNI и порт — общие настройки +Действия пронумерованы подряд от `1` до `13`; `0` завершает программу. SNI и порт — общие настройки инбаунда, поэтому их изменение может затронуть другие профили на том же порту. Fingerprint — клиентская настройка профиля/маршрута; его изменение не перезапускает Xray и не меняет другие маршруты. @@ -114,24 +118,38 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с | `sudo xrayebator update ` | Self-update менеджера из canonical raw-репозитория (ветка branch), продолжить новым скриптом, затем обновить Xray-core; ветка закрепляется в `.current_branch` для последующих обновлений из GUI | | `sudo xrayebator probe-test` | Проверить SNI reachability с VPS | | `sudo xrayebator quickstart --email <адрес>` | Путь одноразового деплоя (используется GUI): broad setup/migration, IP-TLS endpoint на `8443` и стандартный schema-v3 HAPP-профиль из 7 маршрутов; выводит JSON с `subscription_url`. Migration calls best-effort, проверяйте итоговый профиль и сервисы | -| `sudo xrayebator quickstart --without-email` | Тот же путь развёртывания без ACME email; Certbot регистрирует аккаунт с `--register-unsafely-without-email`, поэтому уведомления и восстановление аккаунта по email недоступны | +| `sudo xrayebator quickstart --without-email` | Тот же путь развёртывания без ACME email; Certbot регистрирует аккаунт с `--register-unsafely-without-email`, поэтому уведомления и восстановление аккаунта по email недоступны. Если LE не может проверить http-01 (хостер фильтрует порт 80), quickstart деградирует в `http_tls`: подписка поднимается по HTTP, JSON несёт `degraded:true`, повторный деплой после разблокировки :80 возвращает HTTPS | | `sudo xrayebator inspect --json` | Read-only проверка установки для GUI-импорта: возвращает состояние Xray, профилей и маркеров подписки; не запускает установку, миграции или изменения конфигурации | | `sudo xrayebator happ-setup` | Сокращённый re-entry на существующей установке: проверяет subscription service и usable multi-route profile; при отсутствии markers проверяет IP-TLS endpoint на `8443`, но не фабрикует markers | +| `sudo xrayebator quickstart` — режим http_tls | Если Let's Encrypt не может проверить http-01 для IP (`Connection reset by peer` от validation hosts — обычно порт 80 фильтруется хостером для зарубежных источников), деплой не падает: подписка остаётся только на loopback (публичного URL вообще нет — ключи GUI тянет по SSH с `http://127.0.0.1:8080/sub/` на самом сервере), self-signed серт готовится для будущих бэкендов, итоговый JSON несёт `degraded:true`, `tls_mode:"http_tls"` и причину certbot. Сервер хранится как «Настроен частично» с пометкой деградации; location ACME-challenge на :80 сохраняется — повторный quickstart после разблокировки порта выпускает LE-серт и возвращает подписку на HTTPS | | `sudo xrayebator profiles` | Вывести все профили сервера JSON-массивом (для «Настроек сервера» GUI); срок содержит epoch-секунды (`expire`) для принуждения и календарную дату в зоне сервера (`expire_date`) для отображения, поэтому клиент с другой временной зоной видит выбранную дату | -| `sudo xrayebator profile-create --name ИМЯ [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire ДАТА]` | Создать профили без интерактива; `--expire` принимает `ГГГГ-ММ-ДД[ ЧЧ:ММ]`, epoch-секунды или 13-значные миллисекунды. Дата без времени действует включительно до `23:59:59` по локальным часам сервера; указанное время трактуется в той же зоне. Уже прошедший срок отклоняется; `{"ok":true,"names":[...],"errors":[...]}` | -| `sudo xrayebator profile-delete --name ИМЯ` | Удалить профиль без интерактива; `{"ok":true,"name":"..."}` | +| `sudo xrayebator profile-create --name ИМЯ [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire ДАТА]` | Создать профили без интерактива; `--expire` принимает `ГГГГ-ММ-ДД[ ЧЧ:ММ]`, epoch-секунды или 13-значные миллисекунды. Дата без времени действует включительно до `23:59:59` по локальным часам сервера; указанное время трактуется в той же зоне. Уже прошедший срок отклоняется; существующие имена не перезаписываются — они попадают в `errors` (GUI дополнительно проверяет коллизию имени до создания, поскольку удаление идёт по профилю целиком и уникальность имён делает его прицельным); `{"ok":true,"names":[...],"errors":[...]}` | +| `sudo xrayebator profile-delete --name ИМЯ` | Удалить профиль без интерактива; бэкенд-гранты удалённого профиля отзываются сразу — peer снимается с `awg0.conf`, пароль уходит из `server.yaml` (осиротевших креденшелов нет), имя освобождается для повторного использования; `{"ok":true,"name":"..."}` | | `sudo xrayebator profile-revoke --name ИМЯ [--full]` | Перевыпустить ссылку подписки: новый `sub_token`; с `--full` — ещё и новый uuid во всех inbound'ах профиля (уже скачанные конфиги отваливаются); JSON | | `sudo xrayebator profile-expire --name ИМЯ --expire ДАТА\|epoch\|none` | Задать, продлить или снять срок действия профиля; дата без времени действует включительно до `23:59:59` по локальным часам сервера, указанное время используется как есть в той же зоне; применение немедленное (истёкший срок сразу снимает клиента, продление возвращает); JSON | | `sudo xrayebator expire-check` | Применить все наступившие сроки пакетно; идемпотентно, без изменений не перезапускает Xray. Вызывается таймером `xrayebator-expire.timer` каждые 10 минут | | `sudo xrayebator fp-change --name ИМЯ [--route R] --fp ОТПЕЧАТОК` | Сменить клиентский fingerprint для одного маршрута профиля; JSON | | `sudo xrayebator sni-change --name ИМЯ [--route R] --sni SNI` | Сменить общий inbound SNI и синхронизировать профили на этом порту; JSON | -| `sudo xrayebator sni-list` | Вывести SNI-кандидаты по категориям для GUI; JSON | +| `sudo xrayebator sni-list` | Вывести SNI-кандидаты по категориям для GUI; JSON. Первая незакомментированная строка `sni_list.txt` — SNI по умолчанию для новых профилей (`www.cloudflare.com`) | | `sudo xrayebator port-change --name ИМЯ [--route R] --port ПОРТ\|random` | Сменить порт инбаунда, firewall и метаданные подписки; клиенту переподключиться; JSON | | `sudo xrayebator bypass list` | Показать текущие bypass-правила (JSON) | | `sudo xrayebator bypass add --domain D` | Добавить домен в bypass-правила | | `sudo xrayebator bypass remove --domain D` | Убрать домен из bypass-правил | | `sudo xrayebator bypass reset` | Сбросить все кастомные bypass-правила | | `sudo xrayebator bypass bundle [--group a,b,c]` | Применить дефолтные группы bypass; без `--group` — все группы | +| `sudo xrayebator backend-status` | Статус реестра мультипротокольных бэкендов в JSON (`{"ok":true,"backends":{…}}`) | +| `sudo xrayebator hysteria2-install [--port P] [--grant-all]` | Установить UDP-бэкенд Hysteria 2: бинарник из релизов `HyNetworks/hysteria` (SHA-256), выделенный пользователь `hysteria`, свой systemd-юнит с `CAP_NET_BIND_SERVICE`, QUIC-sysctl буферы, адаптивный TLS (сертификат подписки Let's Encrypt + deploy-hook, иначе self-signed); UDP-порт 443 по умолчанию; JSON (`already: true` если уже стоит) | +| `sudo xrayebator hysteria2-uninstall` | Удалить бэкенд Hysteria 2 (сервис, конфиг, сертификаты, правило firewall); JSON | +| `sudo xrayebator hysteria2-status` | Статус бэкенда Hysteria 2 (JSON) | +| `sudo xrayebator hysteria2-grant --name N` | Выдать per-profile креденшел Hysteria 2 (в `.backends.hysteria2` профиля); серверный конфиг регенерируется из всех профилей; JSON | +| `sudo xrayebator hysteria2-subbody --on\|--off` | Килл-свитч строк `hysteria2://` в обоих телах подписки: флаг живёт в реестре бэкендов, обработчик перечитывает его на каждый запрос, без рестарта сервиса; JSON | +| `sudo xrayebator hysteria2-link --name N` | Ссылка `hysteria2://` профиля с грантом в JSON `{ok, name, link}` (именование как у VLESS-маршрутов, с флагом страны) | +| `sudo xrayebator awg-install [--grant-all]` | Установить системный VPN-бэкенд AmneziaWG 2.0: kernel-модуль через DKMS (PPA `amnezia/ppa`, фолбэк — сборка), интерфейс `awg0` на случайном высоком UDP-порту, junk-параметры как в дефолте Amnezia, `ip_forward` + MASQUERADE; JSON | +| `sudo xrayebator awg-uninstall` | Удалить бэкенд AmneziaWG (интерфейс, конфиг, symlink, правило firewall; пакеты/модуль остаются в системе); JSON | +| `sudo xrayebator awg-status` | Статус бэкенда AmneziaWG (JSON) | +| `sudo xrayebator awg-grant --name N` | Выдать per-profile peer (ключевая пара + preshared key + адрес `10.8.1.x` в `.backends.awg` профиля); `awg0.conf` регенерируется; JSON | +| `sudo xrayebator awg-conf --name N` | Клиентский `.conf` peer-а профиля в JSON `{ok, name, conf}` — full-tunnel AllowedIPs, серверные junk-параметры, endpoint; импорт в клиент AmneziaWG/AmneziaVPN | +| `sudo xrayebator awg-31 --on\|--off` | Переключить формат конфига AWG 3.1 (`HeaderProtectionKey`, `RandomTrailers`, `S3`/`S4`); для новых установок 3.1 включён по умолчанию, тумблер досоздаёт недостающие параметры сервера на месте, предупреждает, что все выданные клиентские `.conf` нужно скачать заново (AmneziaVPN ≥ 5.0.1.5), и регенерирует интерфейс с откатом | | `sudo xrayebator-update [branch]` | Запустить полный `update.sh` lifecycle update; без аргумента — интерактивный выбор ветки | | `sudo xrayebator-uninstall` | Снять сервис и конфигурацию | @@ -151,7 +169,9 @@ legacy-файлы и блоки, ранее созданные Xrayebator, и с Активное Electron-приложение — это CLI-фронтенд поверх SSH, а не полная замена терминальному меню. Оно выполняет деплой через `quickstart`, обновляет сохранённый `subscription_url` и предоставляет -и группы, а также выбранные SNI, fingerprint, port, update и uninstall. +операции профилей SNI, fingerprint, port, update и uninstall, а также панель мультипротокольных +бэкендов: установка/удаление и статус, диалог ключей по профилю (ссылка Hysteria 2 + QR, клиентский +conf AmneziaWG + QR), килл-свитч строк `hysteria2://` в подписке и тумблер AWG 3.1. `probe-test`, HAPP setup, cascade, self-steal, интерактивное меню и диагностика сервиса остаются серверными операциями. @@ -168,7 +188,10 @@ npm test npm run typecheck ``` +## Bypass routing +Bypass routing отправляет выбранные домены мимо туннеля: такой трафик идёт `direct`, остальное +продолжает ходить через VPN. Правила `domain -> direct` стоят выше catch-all, поэтому продолжают работать и при включённом каскаде. @@ -186,6 +209,177 @@ npm run typecheck Меню интерактивное: стрелки двигают выбор, пробел включает и выключает группу, Enter применяет. +## Мультипротокольные бэкенды + +Помимо Xray Reality, на одном VPS можно поднять дополнительные транспорты, управляемые из того же +CLI/меню. Всё состояние бэкендов живёт в нейтральном корне, который не трогает +`/usr/local/etc/xray/`: + +```text +/usr/local/etc/xrayebator/ +├── backends.json # реестр: installed/version/port/флаги по бэкендам (644, без секретов) +└── backends/ + ├── hysteria2/ # server.yaml, сертификаты, placeholder-credential + └── awg/ # awg0.conf, server-params.json (0600) +``` + +Креденшелы per-profile хранятся в самом JSON профиля (`.backends.hysteria2`, `.backends.awg`) — +профиль является единственным источником правды, а серверные конфиги бэкендов всегда +регенерируются из профилей. События жизненного цикла профиля (создание/удаление/revoke/истечение/ +продление) автоматически применяются в каждый установленный бэкенд: revoke ротирует пароль +Hysteria и ключи AWG peer-а, истечение срока снимает гранты из обоих серверных конфигов, продление +возвращает. + +### Hysteria 2 (быстрый UDP) + +`hysteria2-install` скачивает бинарник из официальных релизов `HyNetworks/hysteria` (SHA-256), +создаёт пользователя `hysteria` и отдельный systemd-юнит, слушает UDP 443 по умолчанию (QUIC рядом +с TCP 443 Reality-инбаундом). TLS адаптивный: если у подписки уже есть сертификат Let's Encrypt — +используется он (`insecure=0` у клиентов), иначе генерируется self-signed (`insecure=1`). Пока ни +у одного профиля нет гранта, в auth-карте висит `_xrayebator_placeholder` — Hysteria отвергает +пустую userpass-карту. Ссылки вида +`hysteria2://user:pass@host:port/?sni=…&insecure=0|1#name` добавляются в оба тела подписки (HAPP и +generic); флаг `sub_body` в реестре — выключатель на случай капризного парсера. + +### AmneziaWG 2.0 (системный VPN) + +`awg-install` собирает kernel-модуль через DKMS (основной путь — PPA `amnezia/ppa`; фолбэк — +сборка из исходников, для ядер ≥ 5.6 нужен полный `linux-source`) и поднимает интерфейс `awg0` +через `awg-quick@awg0` на случайном высоком UDP-порту, подсеть `10.8.1.0/24`, `ip_forward` и +MASQUERADE на интерфейсе default-route. Junk-параметры повторяют дефолты Amnezia — диалект, +который любой официальный клиент AmneziaVPN (телефон и десктоп) применяет без сюрпризов: +`Jc` 5, `Jmin` 10, `Jmax` 50, `H1`–`H4` = 1..4 (WG-магия), `S1`/`S2` случайные 12..150 и +`S3`/`S4` случайные 12..64 (все уникальные, `S1+56 ≠ S2`). +Каждый профиль-пир получает ключевую пару, preshared key и первый свободный адрес; клиентский +`.conf` (пункт меню 12 или `awg-conf --name N`) несёт full-tunnel `AllowedIPs` и серверные +junk-параметры, и начинается с самодокументирующейся шапки: профиль работает через +AmneziaVPN/AmneziaWG, а не V2Ray-клиенты (HAPP). В десктоп-GUI те же ключи выдаются per-профиль +кнопкой «Ключи» в Server Settings: ссылка Hysteria 2 и AWG `.conf` текстом с QR и выдачей гранта +в один клик. Код «QR · AmneziaVPN» несёт конфиг в родном для приложения формате `vpn://` +(контейнер `amnezia-awg2`, серверные junk-поля уровня контейнера, `protocol_version`, сжатый +payload) — сырой `.conf`, вставленный в AmneziaVPN, до туннеля доходит не целиком, поэтому для +этого приложения рекомендован именно этот код; обычный QR остаётся для standalone-клиента +AmneziaWG. Изменения peer-ов перезапускают интерфейс с откатом — короткий обрыв туннеля для +всех peer-ов, приемлемо, поскольку гранты и отзывы редки. + +### AWG 2.0 vs 3.x — и почему 3.1 важна против DPI/ТСПУ + +По разделению протокола в upstream параметры AmneziaWG делятся на две группы: + +| Должны совпадать побайтово на сервере и клиенте | Локальные для каждой стороны | +|---|---| +| `S1`–`S4`, `H1`–`H4` | `PersistentKeepalive` (рекомендуется 22–30) | +| 3.0: `HeaderProtectionKey` | 3.0: `ContentPaddingAddition`, `Rekey*`, `Reject*`, `Keepalive*`, `MaxHandshakeAttempts` (число или диапазон `"a-b"`) | +| 3.1: `RandomTrailers` | 3.1: `DisableCookies` | + +Xrayebator реализует набор 3.1: junk-параметры плюс `S1`–`S4` (≥ 12), случайно сгенерированный +`HeaderProtectionKey`, общий для сервера и каждого клиентского `.conf`, и `RandomTrailers = on`. +`DisableCookies` доступен, но по умолчанию выключен (ценой встроенной защиты от amplification). +Новые установки получают 3.1 сразу; для установок до 3.1-этапа — пункт меню 12 → «Режим AWG 3.1» +или `awg-31 --on|--off`: догенерирует недостающие ключи, переключает формат конфига и +регенерирует интерфейс. Предупреждение о миграции в силе: 3.1-ключи живут в группе «должны +совпадать», поэтому после переключения **все ранее выданные клиентские `.conf` нужно +перекачать**, а клиентам нужен AmneziaVPN ≥ 5.0.1.5 — старые отказываются импортировать конфиг +целиком. Проверка по `awg show`: нет `latest handshake` — расходится группа «должны совпадать»; +есть хендшейк, но нет трафика — ищите в `awg-quick`/маршрутизации/firewall. + +### Профиль маршрутизации клиента HAPP + +Эндпоинт подписки возвращает клиенту профиль маршрутизации в заголовке ответа `routing:`. +Управляемый профиль по умолчанию, `xrayebator-default`, задаёт `GlobalProxy: "true"`, поэтому в +туннель уходит всё, кроме приватных диапазонов IPv4. + +Это не то же самое, что [Bypass routing](#bypass-routing). Bypass меняет то, куда запрос отправляет +**сервер**; клиент всё равно заворачивает его в туннель, и адресат видит адрес VPS. На ноде без +каскада общий outbound и так `direct`, поэтому bypass не меняет то, что видит российский сайт. +Не пускать домашний трафик в туннель можно только профилем клиента. + +Профиль уезжает клиенту в заголовке ответа, поэтому большой список `DirectSites` может перерасти +дефолтный 4k-буфер nginx. В генерируемом вхосте подписки буфер поднят (`proxy_buffer_size 32k`) — +и в доменном `location /sub/`, и в IP-режиме (quickstart, `location /`); если перед подпиской стоит +свой реверс-прокси, `proxy_buffer_size` нужно поднять и там, иначе nginx +ответит 502 и напишет в лог `upstream sent too big header`. + +### Генерация из меню + +`HAPP-подписка → 7) Маршрутизация клиента` записывает готовый split-профиль, собранный из тех же +наборов, что использует серверный bypass. `GlobalProxy` остаётся `"true"` — всё незнакомое +продолжает идти в туннель, а российские наборы и `geoip:ru` переезжают в `DirectSites` и +`DirectIp`. Предыдущее переопределение бэкапится рядом с файлом, его удаление возвращает +управляемый профиль по умолчанию. Профиль использует плейсхолдеры (`{{GEOIP_URL}}`, +`{{GEOSITE_URL}}`, `{{LAST_UPDATED}}`), которые разрешаются per-subscriber в момент запроса — +в общем файле нет ни одного токена. + +### Файл переопределения + +| Переменная | По умолчанию | Значение | +|---|---|---| +| `HAPP_ROUTING_ENABLED` | `true` | `false` полностью отключает заголовок `routing:` | +| `HAPP_ROUTING_JSON_FILE` | `/usr/local/etc/xray/.happ_routing.json` | Переопределение оператором | + +Если файл существует и проходит проверку схемы HAPP, он заменяет управляемый профиль для всех +подписчиков. Некорректный JSON игнорируется в пользу профиля по умолчанию, а не рассылается +клиентам. Перезапуск `xrayebator-sub.service` не нужен: файл читается на каждый запрос. + +Вердикт выносится для каждого адресата. При `GlobalProxy: "false"` поведением по умолчанию +становится `direct`, а в туннель уходят только `ProxySites` и `ProxyIp`. При `GlobalProxy: "true"` +всё наоборот, и исключениями работают `DirectSites` и `DirectIp`. + +### Плейсхолдеры + +Файл переопределения читается на каждый запрос, поэтому три значения можно оставить серверу: + +| Плейсхолдер | Чем заменяется | +|---|---| +| `{{GEOIP_URL}}` | `<базовый адрес>/sub//geoip.dat` запрашивающего подписчика | +| `{{GEOSITE_URL}}` | `<базовый адрес>/sub//geosite.dat` запрашивающего подписчика | +| `{{LAST_UPDATED}}` | самое свежее время изменения среди `xrayebator` и двух geo-баз | + +Подстановка выполняется до проверки схемы, поэтому неразрешённый плейсхолдер не пройдёт проверку +на `https://`, и клиенту уйдёт управляемый профиль по умолчанию, а не сломанный. `{{LAST_UPDATED}}` +заодно избавляет от ручного подъёма значения после правки geo-баз. + +Три места, где легко ошибиться: + +- **Telegram ходит по IP-адресам, а не по доменам.** Доменные правила его не ловят. Диапазоны нужно + брать со страницы , а не по памяти: список меняется, + а пропущенный диапазон тихо портит загрузку медиа, пока переписка продолжает работать. +- **`LastUpdated` обязан расти.** HAPP переимпортирует профиль только когда значение больше уже + сохранённого. +- **Geo-базы должны быть доступны клиенту.** Управляемый профиль ссылается на + `/sub//geoip.dat`, а этот путь привязан к конкретному подписчику. Плейсхолдеры выше это + решают; без них базы нужно выложить туда, откуда их скачает любой клиент. +- **Страна в адресе базы не должна быть «не та».** Разметка автономных систем не совпадает между + базами: ipinfo.io и ipwho.is/ip-api могут разнести один и тот же диапазон (например, NODE HOST + LIMITED: Финляндия против Germany/Frankfurt). Управляемый HAPP-профиль шлёт клиента за geo-базами + через `{{GEOIP_URL}}`/`{{GEOSITE_URL}}` этой же подписки, поэтому при http_tls-fallback (публичная + подписка недоступна) клиент, у которого нет готовых баз, не сможет их скачать — HAPP покажет + «no data». Лечится ручной поправкой `/usr/local/etc/xray/.server_country` + (`код|страна|город`) и импортом ключей по SSH, либо восстановлением публичной подписки. + +Пока клиент скачивает новые geo-базы, продолжает работать предыдущий профиль, поэтому неудачная +загрузка оставляет маршрутизацию прежней, а не ломает её. + +### Один ключ — сколько устройств? + +У VLESS, Hysteria 2 и AmneziaWG разная семантика мультидевайса: + +- **VLESS (подписка)** — подписка выдаёт UUID профиля, а UUID не эксклюзивны: любое число + устройств качает одну подписку в HAPP и работает одновременно, каждое открывает свои + Reality-туннели по маршрутам. Маршруты — это транспорты, а не слоты под устройства. +- **Hysteria 2** — авторизация на каждое соединение (`userpass`), сервер не привязывает креденшел + к одной сессии: **одна ссылка `hysteria2://` работает с неограниченного числа устройств + одновременно**, у каждого своё QUIC-соединение. Они делят только полосу сервера. +- **AmneziaWG** — cryptokey routing привязывает peer к ключевой паре, а интерфейс хранит один + endpoint на peer (побеждает последний отправитель). **Один ключ = одно устройство надёжно.** + Два устройства на одном `.conf` воюют за endpoint и поочерёдно переподключаются; за одним NAT + еле живёт, между сетями разваливается. Второму устройству нужен свой профиль/ключ — он получит + свой peer и адрес `10.8.1.x`. +- **nginx** обслуживает только endpoint подписки на 8443 (доставка ключей). Hysteria (UDP 443) и + AmneziaWG (свой UDP-порт) идут мимо nginx, и никакой балансировки между бэкендами нет — это + независимые сервисы на отдельных портах; каждое устройство просто добавляет свою сессию к тому + протоколу, который использует. + ## Каскад и upstream-ноды Каскад — серверный режим outbound и routing, а не новый профиль клиента. Клиент продолжает diff --git a/docs/ru/desktop-gui.md b/docs/ru/desktop-gui.md index 138dc571..a95dfc83 100644 --- a/docs/ru/desktop-gui.md +++ b/docs/ru/desktop-gui.md @@ -44,20 +44,22 @@ GUI показывает лог и состояние шагов, но для в Мастер показывает индекс шагов и живую консоль реально выполненной работы: SSH-подключение, вызов `xrayebator inspect --json`, полученные статусы компонентов, проверку подписки и итог. URL подписки — bearer credential, поэтому его токен маскируется (`…`) до попадания в консоль; пароли и байты ключей туда не попадают вовсе. ### Server keys -Server keys обновляет подписку по сохранённому `subscription_url` и показывает полученные VLESS-маршруты. Каждый VLESS-ключ можно скопировать или показать как QR-код; URL подписки также копируется, есть действие «скопировать всё». Эта страница не создаёт отдельную подписку на сервере и не меняет токен подписки (перевыпуск — на карточках профилей в Server settings). +Server keys обновляет подписку по сохранённому `subscription_url` и показывает полученные VLESS-маршруты. Каждый VLESS-ключ можно скопировать или показать как QR-код; URL подписки также копируется, есть действие «скопировать всё». Эта страница не создаёт отдельную подписку на сервере и не меняет токен подписки (перевыпуск — на карточках профилей в Server settings). Если на сервере есть мультипротокольные бэкенды, страница также показывает их ключи из персистентного локального стора: карточка Hysteria 2 на каждый профиль с грантом (ссылка `hysteria2://` — копия + QR; в чипе карточки указано имя профиля-владельца: `HYSTERIA2 · <профиль> · UDP :<порт>`), и карточка AmneziaWG на каждый профиль с грантом — клиентский `.conf`, где `Endpoint`/`Адрес` выведены отдельными полями, а полный `.conf` без служебной шапки доступен текстом и QR для импорта в AmneziaVPN/AmneziaWG. Карточки бэкендов рисуются из стора мгновенно и обновляются параллельно с VLESS-маршрутами; спиннер живёт внутри карточки и виден, только пока её данные загружаются. У AWG-карточки два QR: обычный — для standalone-клиента AmneziaWG, и «QR · AmneziaVPN» — родной сжатый `vpn://`-формат приложения (контейнер `amnezia-awg2`, серверные junk-поля, `protocol_version` 3.1), рекомендуемый путь импорта для полного приложения AmneziaVPN. ### Server settings Server settings сначала аутентифицируется по SSH. Если у карточки уже есть SSH-пароль или persistent-ключ в системном keychain, страница подключается автоматически и показывает только профильную панель; форма доступа появляется лишь когда сохранённого секрета нет или подключение не удалось. Сводка доступа и действие «Изменить доступ» живут на карточке сервера в дашборде, а не внутри страницы профилей. После подключения страница позволяет: - получить список существующих профилей; -- создать один или несколько профилей и удалить профили; -- выбрать транспорты `xhttp`, `tcp`, `tcp-utls`, `tcp-xudp`, `tcp-mux` или `grpc`; +- создать один или несколько профилей и удалить профили (имена уникальны: GUI проверяет коллизию имени до создания — удаление идёт по профилю целиком, и уникальность имён делает его прицельным); +- выбрать транспорты `xhttp`, `tcp`, `tcp-utls`, `tcp-xudp`, `tcp-mux` или `grpc` — либо выбрать один из двух мультипротокольных бэкендов (карточки Hysteria 2 / AmneziaWG 3.1 под сеткой транспортов). Клик по бэкенд-карточке только выбирает протокол (выбранный VLESS-транспорт при этом снимается и наоборот); профиль создаётся кнопкой «Создать профиль», и ключ выдаётся только для выбранного бэкенда — подкапотный VLESS-профиль создаётся на рекомендованном транспорте `xhttp`; - изменить fingerprint профиля, выбрать SNI из `sni-list` или ввести SNI вручную; - изменить порт маршрута профиля или выбрать случайный порт; -- обновить установку на сервере; +- обновить установку на сервере (меню веток: «Как закреплено на сервере» — `.current_branch`, иначе main; **main — последняя стабильная версия**; **dev — последняя версия со всеми фишками, но может быть нестабильной**; возврат с dev — обновлением на main); - удалить установку после подтверждения; -- сбросить закреплённый SSH host key после явного подтверждения. +- сбросить закреплённый SSH host key после явного подтверждения; +- управлять мультипротокольными бэкендами (паритет с пунктами меню 11–13): общая панель «дополнительные протоколы» (они находятся в разработке — интерфейс и поведение могут меняться) показывает состояние Hysteria 2 и AmneziaWG (версия, порт, TLS-режим / интерфейс, сводка junk) с установкой/удалением, килл-свитчем строк `hysteria2://` в подписке и тумблером AWG 3.1 — последние два с явным подтверждением; +- бэкенд-боксы по каждому профилю прямо в списке профилей: заголовок — имя профиля, в подписи протокол, зелёная строка статуса («Активен · SNI …» или «Активен · UDP <порт>»); действия — QR (плюс «QR · AmneziaVPN» у AmneziaWG), Срок, Revoke и Удалить. Удаление профиля отзывает его бэкенд-ключи немедленно — peer снимается с `awg0.conf`, пароль уходит из `server.yaml` (осиротевших креденшелов нет), имя освобождается для повторного использования. Полное копирование — на странице Server keys. SNI и порт — параметры уровня inbound: их изменение может затронуть все профили, использующие тот же inbound. Fingerprint устроен иначе: это клиентское значение конкретного профиля/маршрута, оно не меняет остальные маршруты. Серверная команда сообщает результат и необходимость переподключения. @@ -92,6 +94,14 @@ xrayebator port-change --name NAME [--route R] --port PORT|random xrayebator bypass list|add --domain D|remove --domain D|reset|bundle [--group a,b,c] ``` +Для управления бэкендами дополнительно вызываются: + +```text +xrayebator backend-status +xrayebator hysteria2-install [--port P] [--grant-all] / hysteria2-uninstall / hysteria2-grant --name N / hysteria2-subbody --on|--off / hysteria2-link --name N +xrayebator awg-install [--grant-all] / awg-uninstall / awg-grant --name N / awg-conf --name N / awg-31 --on|--off +``` + При развёртывании дополнительно вызывается один из вариантов: ```text diff --git a/docs/ru/security.md b/docs/ru/security.md index 4bce9b1a..c507def1 100644 --- a/docs/ru/security.md +++ b/docs/ru/security.md @@ -62,6 +62,27 @@ URL подписки — bearer-credential. Он не публичен, но л - не отдавать внешнему клиенту local-only URL; - не держать на том же домене чужие панели и прокси, не разобравшись в конфиге nginx. +## Безопасность мультипротокольных бэкендов + +Опциональные бэкенды (Hysteria 2, AmneziaWG) следуют той же модели доверия, что и сервис Xray, с +уточнениями по каждому: + +| Путь или класс | Владелец и доступ | +|---|---| +| `/usr/local/etc/xrayebator/backends.json` | `root:root 644` — намеренно без секретов (порты, версии, флаги); аккаунт `xray` читает его из subscription handler | +| `/usr/local/etc/xrayebator/backends/hysteria/` | `root:hysteria 750`; `server.yaml` и сертификаты `0640 root:hysteria` — сервис обязан их читать, больше никто их не трогает | +| `/usr/local/etc/xrayebator/backends/awg/` | `root:root 700`; `awg0.conf` и `server-params.json` `0600` — серверные приватные ключи и секреты peer-ов | +| Placeholder-credential Hysteria | `_xrayebator_placeholder` со случайным паролем в `0600`-файле; существует только чтобы auth-карта не была пустой, выданным клиентам не выдаётся | +| certbot renewal deploy-hook | `/etc/letsencrypt/renewal-hooks/deploy/xrayebator-hysteria.sh`, root `0755`; копирует продлённые сертификаты в каталог бэкенда и рестартует бэкенд | +| `/etc/amnezia/amneziawg/awg0.conf` | symlink на управляемый файл — дистрибутивный юнит `awg-quick@awg0` читает через него; второй копии конфига не существует | + +Hysteria работает от выделенного пользователя `hysteria` с +`AmbientCapabilities=CAP_NET_BIND_SERVICE` и `NoNewPrivileges=true`; sysctl-буферы QUIC лежат в +`/etc/sysctl.d/99-xrayebator-hysteria.conf`. AmneziaWG требует `net.ipv4.ip_forward=1` (свой +sysctl-файл) и добавляет MASQUERADE/FORWARD-правила через `PostUp`/`PostDown` конфига интерфейса, +скоупированные на подсеть бэкенда. Бэкенды ставятся только явным действием оператора и снимаются +соответствующей `*-uninstall` командой; `uninstall.sh` в этом не участвует. + ## Доступ к VPS по SSH Установить Xrayebator можно прямо из-под `root`, но лучше отдельный пользователь с узко ограниченным sudo. diff --git a/docs/ru/testing.md b/docs/ru/testing.md index c75ececf..8c874ca6 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -16,7 +16,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done ## Что покрывают тесты -В `validation/` лежат 28 статических и локальных регрессионных тестов: +В `validation/` лежат 34 статических и локальных регрессионных теста: | Тест | Что проверяет | |---|---| @@ -24,7 +24,9 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `test-project-update-rollback.sh` | Откат неудачного обновления проекта | | `test-xhttp-route-path-repair.sh` | Починку путей XHTTP-маршрутов при миграции | | `test-multiroute-argument-preservation.sh` | Сохранение transport-аргументов multiroute-профиля | -| `test-happ-subscription-static.sh` | Обработчик HAPP-подписки | +| `test-happ-subscription-static.sh` | Обработчик HAPP-подписки (плейсхолдеры routing, порядок валидации) | +| `test-happ-client-routing.sh` | HAPP клиентский split-профиль: генератор из bypass-наборов (258 доменов, плейсхолдеры, `geoip:ru`), nginx-буферы, проводка меню | +| `test-legacy-profile-port-sync.sh` | Легаси-профили без `routes` получают обновления SNI/порта | | `test-subscription-server-name.sh` | Имя сервера подписки в клиенте | | `test-fingerprint-subscription-sync.sh` | Синхронность маршрутов и подписки при смене fingerprint | | `test-dead-stealth-route-pruning.sh` | Отсечение мёртвых stealth-маршрутов | @@ -36,7 +38,10 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `test-installer-network-fallbacks.sh` | Сетевые fallback'и установщика | | `test-bbr-removal-migration.sh` | Безопасное удаление удалённого BBR/TCP tuning на всех путях | | `test-legacy-udp443-migration.sh` | Одноразовое удаление legacy правила блокировки UDP/443 | -| `test-main-menu-numbering.sh` | Нумерацию пунктов меню и их соответствие обработчикам | +| `test-main-menu-numbering.sh` | Нумерацию пунктов меню и их соответствие обработчикам (1..13 с бэкенд-пунктами) | +| `test-backend-registry.sh` | Реестр бэкендов (мультипротокольный этап): source-mode CRUD через `safe_jq_write`, чтение installed/field, lifecycle-точка как безопасный no-op, форма JSON `backend-status` | +| `test-hysteria2-lifecycle.sh` | Бэкенд Hysteria 2: рендер `server.yaml` (userpass-карта, placeholder-fallback), модель безопасности юнита, arch-маппинг, TLS-детект, события грантов/revoke/expire-restore, реген deleted при заранее удалённом файле профиля, created = no-op (без авто-выдачи), pure-строитель `hysteria2://`, проводка диспетчера и подписки | +| `test-awg-lifecycle.sh` | Бэкенд AmneziaWG: junk-диалект по умолчанию Amnezia (Jc=5, Jmin=10, Jmax=50, H1–H4=1..4, S1–S4 уникальные), генерация ключей, рендер серверного конфига (PostUp MASQUERADE, без peer-ов при установке), аллокация адресов, содержимое клиентского `.conf`, события revoke/expire/restore, реген deleted при заранее удалённом файле профиля, created = no-op (без авто-выдачи), диспетчер CLI | | `test-main-readiness-regressions.sh` | Регрессии readyness после аудита: certbot-manifest, UFW manifest, nginx rollback, привилегии, SSH-порт | | `test-sni-change-cli.sh` | CLI `sni-change`: JSON stdout, Reality, XHTTP host, синхронизацию, rollback | | `test-bypass-cli.sh` | CLI `bypass`: JSON stdout, routing-правила, add с проверкой SNI | @@ -46,6 +51,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `test-quickstart-email-and-inspect.sh` | Явный email-режим `quickstart` (`--without-email` без фиктивного адреса) и read-only инварианты `inspect --json` | | `test-quickstart-migration-parity.sh` | `quickstart` гоняет те же критичные миграции, что и `main_menu` | | `test-quickstart-subscription-port.sh` | `quickstart` использует canonical helper базы подписки и не возвращается к несвязанному hardcode URL | +| `test-quickstart-tls-fallback.sh` | Ветку graceful-degradation `http_tls`: переключение SUB_TLS_MODE, HTTP-only vhost (без ssl и proxy), маркеры с учётом режима, renew-таймер только в ip_tls и degraded-метаданные JSON | | `test-audit-functional.sh` | Функциональные regression-проверки аудита HowDeploy (P0/P1) | | `test-audit-privilege-regressions.sh` | Regression границ привилегий | @@ -105,7 +111,7 @@ npm test # Vitest unit-тесты Три независимых workflow: -- **ci-linux.yml** — Bash validation: `bash -n` всех скриптов + все 28 `validation/test-*.sh` на +- **ci-linux.yml** — Bash validation: `bash -n` всех скриптов + все 34 `validation/test-*.sh` на ubuntu-24.04. Запускается на push в `main`, `dev`, `experimental` и на pull request. - **release.yml** — Electron сборка (Windows/macOS/Linux). Запускается только на теги `v*` и manual dispatch. Сначала `preflight`: проверяет наличие текста релиза `docs/releases/.en.md` и diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index 4b7fd5f8..75c8ea2b 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -206,10 +206,114 @@ sudo systemctl restart sshd Несколько профилей одновременно использовать можно: разные подписки, SNI, порты и маршруты дают больше вариантов обхода блокировок, но делят общие ресурсы VPS. +## Сервис Hysteria 2 не стартует + +Сначала причина — установка откатывает артефакты, состояние нужно воспроизвести: + +```bash +journalctl -u hysteria-server.service --no-pager | tail -20 +``` + +Известные режимы отказа (в генераторе исправлены, актуальны при ручной правке конфигов): + +- `invalid config: auth.userpass: empty auth userpass` — ключ auth-карты называется `userpass`, а + **не** `users`, и пустая карта отвергается. Генератор всегда рендерит хотя бы + `_xrayebator_placeholder`, пока ни у одного профиля нет гранта; при ручной правке `server.yaml` + держите под `userpass:` непустую карту. +- `Changing to the requested working directory failed: Permission denied` — каталог бэкенда должен + быть `root:hysteria 0750`; `chmod` без парного `chown` оставляет `root:root`, и юнит не может + войти в каталог. +- Сертификат нечитаем — в режиме `le` это копии с владельцем `root:hysteria 0640`; renewal + deploy-hook обновляет их при продлении. + +После исправления чистый путь: `hysteria2-uninstall`, затем свежий `hysteria2-install`. + +## AWG: интерфейс поднят, но нет `latest handshake` + +AmneziaWG сообщает о несовпадении параметров молчанием: peer просто никогда не получает хендшейк. +Проверьте группу «должны совпадать» — она обязана быть идентичной на сервере и в каждом клиентском +`.conf`: + +- `S1`–`S4` (≥ 12 для движков 3.x), `H1`–`H4`; +- 3.x-ключи, если они есть на другой стороне: `HeaderProtectionKey` (3.0) и `RandomTrailers` (3.1) + совпадают побайтово. Xrayebator эмитит их только в режиме 3.1 (`awg-31 --on`, для новых установок + включён по умолчанию); несовпадение обычно означает, что клиентский `.conf` старше переключения + на 3.1 — перекачайте его — либо смешан с чужим/ручным конфигом; +- версия клиентского приложения: конфиги с `RandomTrailers` требуют AmneziaVPN ≥ 5.0.1.5; более + старые могут отказаться импортировать конфиг целиком. + +Затем разделите зоны отладки: `awg show awg0` на сервере — если peer в списке, хендшейк есть, а +трафика нет, проблема в зоне `awg-quick`/маршрутизации/firewall (`ip_forward`, интерфейс +MASQUERADE), а не в параметрах протокола. + +Прежде чем винить параметры, проверьте сам сетевой путь. Сокет AWG живёт в ядре, поэтому UDP-порт +не виден в `ss`/`netstat` — увидеть что-либо можно только захватом на сервере в момент, когда +клиент пытается подключиться: + +```bash +tcpdump -l -ni 'udp port ' +``` + +Пусто → пакеты не доходят вовсе: фильтрация оператора/DPI (типичный паттерн РФ — Hysteria 2 на +UDP 443 проходит, а высокий случайный UDP-порт молча дропается). Захват показывает пакеты, а +`awg show` по-прежнему без хендшейка → клиент шлёт «мусор»: перевыпустите `.conf`, проверьте +версию приложения (3.1-параметры требуют AmneziaVPN ≥ 5.0.1.5) или временно `awg-31 --off` и +переимпортируйте для проверки 2.0-совместимости. Учтите: `tcpdump` без `-l` при перенаправлении в +файл блочно-буферизует вывод — ранние пакеты могут не появиться до сброса буфера. + +## AmneziaVPN (полное приложение) падает с ошибкой 1000, standalone-клиент работает + +`ошибка 1000` — зонтичный `AndroidError` приложения; в логе телефона при этом +`VPN config format error: No value for client_ip`. Нативный конвейер приложения принимает только +свой формат экспорта — контейнер `amnezia-awg2`, серверные junk-поля рядом с `last_config`, +`protocol_version` и сжатый (`qCompress`) payload. Сырой `.conf` (или голый `vpn://` без этих +полей) сохраняется, но клиентская часть до туннеля не доходит. Код «QR · AmneziaVPN» в десктоп-GUI +собирает ровно этот shape — используйте его вместо вставки `.conf`. Junk-группа, которую ставит +Xrayebator, повторяет дефолты Amnezia (`Jc` 5, `Jmin` 10, `Jmax` 50, `H1`–`H4` = 1..4) именно +потому, что их go-туннель применяет кастомный junk не целиком. + +## Установка AWG падает + +- Основной путь — PPA `amnezia/ppa`; на сериях дистрибутива без сборок PPA Xrayebator уходит в + сборку из исходников. Для ядер ≥ 5.6 нужен **полный** пакет `linux-source` — одних заголовков + недостаточно (сборка модуля линкует всё дерево исходников). +- Проверка после установки: `lsmod | grep amneziawg` показывает модуль, `awg --version` отвечает. +- Юнит `awg-quick@awg0` читает `/etc/amnezia/amneziawg/awg0.conf` — Xrayebator держит его как + symlink на `/usr/local/etc/xrayebator/backends/awg/awg0.conf`. Удаление symlink ломает юнит, + хотя сам конфиг бэкенда валиден. +- При uninstall пакеты и kernel-модуль намеренно остаются в системе; удаляются интерфейс, конфиг, + symlink и правило firewall. + +## В подписке нет строки `hysteria2://` + +Ссылки `hysteria2://` добавляются в оба тела подписки только при выполнении всех условий: + +1. бэкенд Hysteria 2 установлен (`xrayebator hysteria2-status`); +2. у профиля есть грант (JSON `profiles` → `.backends.hysteria2.password`); +3. флаг реестра `sub_body` равен `true` (выключатель — меняется в `backends.json`, handler + перечитывает реестр на каждый запрос, рестарт сервиса не нужен). + +Истёкший или отключённый профиль строки не получает; у отозванного на следующей загрузке подписки +новый credential. `404` на ранее работавший токен после revoke — ожидаемо: сам токен ротирован. + ## quickstart падает с «apt-get install nginx failed» На свежем Ubuntu-VPS `unattended-upgrades` может держать apt/dpkg lock около 10 минут и вызывать `dpkg` отдельно для каждого пакета, поэтому простая проверка lock проскакивает в зазор между пакетами. quickstart теперь дополнительно ждёт активного воркера `unattended-upgrade` (бюджет 12 минут) и передаёт `apt-get install` опцию `-o DPkg::Lock::Timeout=180`. Повторите деплой, когда quickstart сообщит, что apt занят дольше бюджета, или дождитесь окончания очереди. Поведение закреплено в `validation/test-apt-lock-race.sh`. +## quickstart пишет «certbot failed: ... Connection reset by peer», но завершается успешно + +Let's Encrypt не смог скачать http-01 challenge — чаще всего сеть хостера фильтрует +порт 80 для зарубежных источников (проверено tcpdump'ом и мультиузловыми пробами: +наш firewall и nginx исправны, reset происходит upstream). С этим деградационным +режимом деплой завершается в fallback `http_tls`: итоговый JSON несёт `degraded:true` +и `tls_mode:"http_tls"`, GUI показывает сервер как «Настроен частично», ключи +загружаются по SSH. Публичного URL подписки в этом режиме нет вовсе — +уязвимой HTTP-ссылки не появляется. Чтобы вернуть HTTPS, попросите хостера разблокировать порт 80 для +validation-диапазонов Let's Encrypt (или направьте домен на сервер и используйте +доменный TLS-режим), затем повторите деплой — он идемпотентен и выпустит LE-серт, +когда challenge станет доступен. Структуру fallback закрепляет +`validation/test-quickstart-tls-fallback.sh`. + ## Вылезла ошибка при установке или работе Скопируйте текст ошибки из терминала целиком. Если проблема в коде Xrayebator — открывайте issue. \ No newline at end of file diff --git a/docs/security.md b/docs/security.md index a2ff8787..8ee410b7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -68,6 +68,27 @@ Left to the operator: - do not host third-party panels or proxies on the same domain without understanding the nginx configuration. +## Multi-protocol backend security + +Optional backends (Hysteria 2, AmneziaWG) follow the same trust model as the Xray service, with +per-backend refinements: + +| Path or class | Owner and access | +|---|---| +| `/usr/local/etc/xrayebator/backends.json` | `root:root 644` — deliberately secret-free (ports, versions, flags only); the `xray` service account reads it from the subscription handler | +| `/usr/local/etc/xrayebator/backends/hysteria/` | `root:hysteria 750`; `server.yaml` and certificates `0640 root:hysteria` — the service must read them, nothing else touches them | +| `/usr/local/etc/xrayebator/backends/awg/` | `root:root 700`; `awg0.conf` and `server-params.json` `0600` — server private keys and peer secrets | +| Hysteria placeholder credential | `_xrayebator_placeholder` with a random password in a `0600` file; exists only so the auth map is never empty, not used by any issued client | +| certbot renewal deploy-hook | `/etc/letsencrypt/renewal-hooks/deploy/xrayebator-hysteria.sh`, root-owned `0755`; copies renewed certificates into the backend dir and restarts the backend | +| `/etc/amnezia/amneziawg/awg0.conf` | a symlink to the managed file — the distro `awg-quick@awg0` unit reads through it; no second copy of the config exists | + +Hysteria runs as the dedicated `hysteria` user with `AmbientCapabilities=CAP_NET_BIND_SERVICE` and +`NoNewPrivileges=true`; the QUIC buffer sysctls live in `/etc/sysctl.d/99-xrayebator-hysteria.conf`. +AmneziaWG requires `net.ipv4.ip_forward=1` (its own sysctl file) and adds MASQUERADE/FORWARD rules +through the interface config's `PostUp`/`PostDown`, scoped to the backend's subnet. Backend +services are installed only on explicit operator action and removed with the corresponding +`*-uninstall` command; `uninstall.sh` is not involved. + ## SSH access to the VPS Xrayebator can be installed straight from `root`, but a dedicated user with narrowly scoped sudo is diff --git a/docs/testing.md b/docs/testing.md index b3647727..3707db79 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -36,7 +36,7 @@ promise that installer and updater paths behave identically. ## Validation suite -`validation/` contains exactly 28 scripts. Run every `validation/test-*.sh`; the current set is: +`validation/` contains exactly 34 scripts. Run every `validation/test-*.sh`; the current set is: | Script | What it checks | |---|---| @@ -48,10 +48,15 @@ promise that installer and updater paths behave identically. | `test-cascade-upstream-import.sh` | Cascade upstream import from a VLESS link | | `test-dead-stealth-route-pruning.sh` | Pruning dead stealth routes | | `test-fingerprint-subscription-sync.sh` | Fingerprint changes and subscription synchronisation | -| `test-happ-subscription-static.sh` | Static HAPP subscription handler behavior | +| `test-happ-subscription-static.sh` | Static HAPP subscription handler behavior (routing placeholders, schema validation order) | +| `test-happ-client-routing.sh` | HAPP client split-profile: generator from bypass bundles (258 domains, placeholders, `geoip:ru`), nginx proxy buffers, menu wiring | +| `test-legacy-profile-port-sync.sh` | Legacy profiles without `routes` still receive SNI/port sync updates | | `test-installer-network-fallbacks.sh` | Installer network and resolver fallbacks | | `test-legacy-udp443-migration.sh` | One-time removal of the legacy UDP/443 block | -| `test-main-menu-numbering.sh` | Consecutive menu numbering and matching handlers | +| `test-main-menu-numbering.sh` | Consecutive menu numbering and matching handlers (1..13 including the backend items) | +| `test-backend-registry.sh` | Backend registry (multi-protocol stage): source-mode registry CRUD via `safe_jq_write`, installed/field reads, the lifecycle choke point as a safe no-op, `backend-status` JSON shape | +| `test-hysteria2-lifecycle.sh` | Hysteria 2 backend: `server.yaml` renderer (userpass map, placeholder fallback), systemd unit security model, arch mapping, TLS detection, grants/revocation/expire-restore events, deleted-regen with a pre-removed profile file, created-is-a-no-op (no auto-grant), pure `hysteria2://` builder, CLI dispatch and subscription wiring | +| `test-awg-lifecycle.sh` | AmneziaWG backend: the Amnezia-default junk dialect (Jc=5, Jmin=10, Jmax=50, H1–H4=1..4, S1–S4 unique), key generation, server conf renderer (PostUp MASQUERADE, no peers at install), peer address allocation, client `.conf` contents, revoke/expire/restore events, deleted-regen with a pre-removed profile file, created-is-a-no-op (no auto-grant), CLI dispatch | | `test-main-readiness-regressions.sh` | Main-menu readiness and first-run regression checks | | `test-multiroute-argument-preservation.sh` | Preservation of multiroute transport arguments | | `test-port-change-cli.sh` | Port-change CLI scenarios, firewall moves and route selection | @@ -61,6 +66,7 @@ promise that installer and updater paths behave identically. | `test-quickstart-email-and-inspect.sh` | Explicit `quickstart` email mode (`--without-email` without a fake address) and the read-only invariants of `inspect --json` | | `test-quickstart-migration-parity.sh` | Parity between quickstart and main-menu migrations | | `test-quickstart-subscription-port.sh` | Ensures quickstart uses the canonical subscription base helper and does not regress to an unrelated hardcoded URL | +| `test-quickstart-tls-fallback.sh` | The http_tls graceful-degradation branch: SUB_TLS_MODE switching, HTTP-only vhost (no ssl, no proxy), mode-aware markers, the ip_tls-only renew timer and degraded JSON metadata | | `test-sni-change-cli.sh` | SNI-change JSON output, transport fields, profile sync and rollback | | `test-subscription-server-name.sh` | HAPP subscription display name | | `test-transaction-safety.sh` | Transactional safety of configuration operations | @@ -106,6 +112,14 @@ migration does not retrofit missing routes. If UFW is already active, compare th before and after an operation: the installer adds the fixed project service TCP list and must not change the default policy, while uninstall should remove only rules recorded as owned. +For the optional backends, a disposable VPS run should additionally cover: `hysteria2-install +--grant-all` (service active, UDP listener present, grant in `server.yaml`), `awg-install +--grant-all` (module in `lsmod`, `awg show awg0` shows the peer), a `profile-revoke` cycle rotating +both grants, an expire/restore cycle, and both subscription bodies containing exactly one +`hysteria2://` line per granted profile. CI covers the renderers, registry integration and dispatch +wiring with mocked binaries; the package installation itself (network, DKMS build, real systemctl) +and a real client connection are live-server checks by design. + ## Electron GUI unit tests The active Electron GUI has exactly fourteen Vitest unit files: @@ -149,7 +163,7 @@ provide useful local coverage, while CI runs the Electron typecheck and unit sui The workflows have separate responsibilities: - `.github/workflows/ci-linux.yml` is the Bash core gate on `ubuntu-24.04`: it installs `jq`, - `uuid-runtime` and `ripgrep`, runs all four Bash syntax checks, then runs all 28 validation + `uuid-runtime` and `ripgrep`, runs all four Bash syntax checks, then runs all 34 validation scripts. - `.github/workflows/release.yml` is the active Electron release path for `v*` tags or manual runs. A `preflight` job first checks that the release notes file `docs/releases/.en.md` exists and diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8be2244c..9a3b1ee2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -231,10 +231,117 @@ count and provider limits. Grow the user count gradually and watch the load. Multiple profiles can be used at once: different subscriptions, SNIs, ports and routes give more options for bypassing blocks, but they share the same VPS resources. +## Hysteria 2 service does not start + +Read the reason first — the install rolls its artifacts back, so reproduce the state: + +```bash +journalctl -u hysteria-server.service --no-pager | tail -20 +``` + +Known failure modes, all fixed in the generator but relevant when hand-editing configs: + +- `invalid config: auth.userpass: empty auth userpass` — the auth map key is `userpass`, **not** + `users`, and an empty map is rejected. The generator always renders at least the + `_xrayebator_placeholder` user while no profile has a grant; if you hand-edit `server.yaml`, keep + a non-empty map under `userpass:`. +- `Changing to the requested working directory failed: Permission denied` — the backend directory + must be `root:hysteria 0750`; a `chmod` without the matching `chown` keeps it `root:root` and the + unit cannot enter it. +- Certificate unreadable — in `le` mode the certificates are copies owned `root:hysteria 0640`; the + renewal deploy-hook refreshes them. + +After fixing, the clean path is `hysteria2-uninstall` followed by a fresh `hysteria2-install`. + +## AWG: interface is up but there is no `latest handshake` + +AmneziaWG reports parameter mismatches by silence: the peer simply never appears with a handshake. +Check the must-match group — these must be identical on the server and in every client `.conf`: + +- `S1`–`S4` (≥ 12 for 3.x engines), `H1`–`H4`; +- any 3.x keys present on the other side: `HeaderProtectionKey` (3.0) and `RandomTrailers` (3.1) + must match byte-for-byte. Xrayebator emits them only in 3.1 mode (`awg-31 --on`, on by default + for new installs); a mismatch usually means the client `.conf` predates a 3.1 switch — + re-download it — or was mixed with a hand-made or third-party one; +- the client application version: `RandomTrailers`-era configs need AmneziaVPN ≥ 5.0.1.5; older + clients may refuse to import the config entirely. + +Then split the debug zones: `awg show awg0` on the server — if the peer is listed with a handshake +but no traffic passes, the problem is in the `awg-quick`/routing/firewall zone (`ip_forward`, +MASQUERADE interface), not in the protocol parameters. + +Before blaming the parameters, verify the network path itself. The AWG socket lives in kernel +space, so the UDP port is invisible to `ss`/`netstat` — the only way to see anything is a capture +on the server while the client tries to connect: + +```bash +tcpdump -l -ni 'udp port ' +``` + +Nothing captured → the packets never arrive: carrier/DPI filtering (a typical RU pattern is +Hysteria 2 on UDP 443 passing while a high random UDP port is silently dropped). The capture shows +packets but `awg show` still reports no handshake → the client is sending garbage: re-issue the +`.conf`, check the app version (3.1 params need AmneziaVPN ≥ 5.0.1.5), or temporarily run +`awg-31 --off` and re-import to test 2.0 compatibility. Note that `tcpdump` without `-l` +block-buffers its output when redirected to a file — early packets may not appear until the buffer +flushes. + +## AmneziaVPN (the full app) fails with error 1000, the standalone client works + +`error 1000` is the app's generic `AndroidError`; the phone-side log then shows +`VPN config format error: No value for client_ip`. The app's native pipeline only accepts its own +export shape — the `amnezia-awg2` container, server-side junk fields next to `last_config`, +`protocol_version` and a compressed (`qCompress`) payload. A raw `.conf` (or a bare `vpn://` +without those fields) is stored, but the client part never reaches the tunnel. The desktop GUI's +«QR · AmneziaVPN» code builds exactly that shape — use it instead of pasting a `.conf`. The junk +group Xrayebator installs mirrors the Amnezia defaults (`Jc` 5, `Jmin` 10, `Jmax` 50, +`H1`–`H4` = 1..4) precisely because the app's go-tunnel applies custom junk incompletely. + +## AWG install fails + +- The primary path is the `amnezia/ppa` PPA; on distribution series without PPA builds Xrayebator + falls back to a source build. For kernels ≥ 5.6 that build needs the **full** `linux-source` + package — kernel headers alone are not enough (the module build links the whole source tree). +- Verify after install: `lsmod | grep amneziawg` shows the module, `awg --version` answers. +- The `awg-quick@awg0` unit reads `/etc/amnezia/amneziawg/awg0.conf` — Xrayebator maintains it as a + symlink to `/usr/local/etc/xrayebator/backends/awg/awg0.conf`. Deleting the symlink breaks the + unit while the backend config remains valid. +- On uninstall the packages and the kernel module deliberately stay in the system; only the + interface, config, symlink and firewall rule are removed. + +## The subscription has no `hysteria2://` line + +`hysteria2://` links are appended to both subscription bodies only when all of these hold: + +1. the Hysteria 2 backend is installed (`xrayebator hysteria2-status`); +2. the profile has a grant (`profiles` JSON → `.backends.hysteria2.password`); +3. the registry flag `sub_body` is `true` (the kill switch — flip it in `backends.json`, the + handler re-reads the registry on every request, no service restart needed). + +An expired or disabled profile never gets the line; a revoked profile gets a new credential on the +next subscription fetch. A `404` on a previously working token URL after a revoke is expected — the +token itself was rotated. + ## Quickstart fails with `apt-get install nginx failed` On a freshly provisioned Ubuntu VPS, `unattended-upgrades` may hold the apt/dpkg lock for ~10 minutes and invoke `dpkg` separately for every package, so a plain flock check slips into the gap between packages. The quickstart path now also waits for an active `unattended-upgrade` worker (12-minute budget) and passes `-o DPkg::Lock::Timeout=180` to `apt-get install`. Rerun the deployment when it reports the lock is still busy, or wait for the queue to finish. `validation/test-apt-lock-race.sh` locks these behaviors. +## Quickstart reports `certbot failed: ... Connection reset by peer` but still succeeds + +Let's Encrypt could not fetch the http-01 challenge — most often the hoster's +network filters port 80 for foreign sources (verified with tcpdump and +multi-node probes: our firewall and nginx are fine, the reset happens upstream). +Since this degradation the deploy finishes in `http_tls` fallback mode: the +result JSON carries `degraded:true` and `tls_mode:"http_tls"`, the GUI shows the +server as *Partially configured*, and keys are loaded over SSH. There is **no +public subscription URL at all** in this mode — no leakable HTTP link exists. +To restore +HTTPS, ask the hoster to unblock port 80 for Let's Encrypt validation ranges +(or point a domain at the server and use the domain TLS mode), then re-run the +deploy; the run is idempotent and issues the LE certificate when the challenge +becomes reachable. `validation/test-quickstart-tls-fallback.sh` locks the +fallback structure. + ## An error appeared during installation or use Copy the full error text from the terminal. For installation failures, include the relevant diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index 37954d8d..68941127 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -51,7 +51,7 @@ Xrayebator/ /usr/local/etc/xray/ ├── config.json # 入站、出站、路由和 DNS -├── profiles/.json # 配置档元数据、订阅令牌与有效期(.expire) +├── profiles/.json # 配置档元数据、订阅令牌、有效期(.expire)、后端授权(.backends) ├── upstreams/cascade.json # 级联上游参数 ├── backups/ # 运行时改动前的配置备份 ├── .private_key / .public_key # Reality 密钥 @@ -61,10 +61,18 @@ Xrayebator/ ├── .current_branch # 管理器用于生命周期更新的分支 └── 迁移标记 # 已完成一次性迁移的记录 +/usr/local/etc/xrayebator/ # 多协议后端根目录(中性,不触碰 Xray 路径) +├── backends.json # 后端注册表:installed/version/port/开关(644,无机密) +└── backends/ + ├── hysteria2/ # server.yaml(按配置档重新生成)、证书 + └── awg/ # awg0.conf(0600)、server-params.json(0600) + /usr/local/share/xray/ # geoip.dat 和 geosite.dat /var/log/xray/ # Xray 服务写入的运行时日志目录 /etc/systemd/system/xray.service.d/security.conf /etc/systemd/system/xrayebator-sub.service +/etc/systemd/system/hysteria-server.service # Hysteria 2 后端(可选) +/etc/systemd/system/awg-quick@awg0.service # AmneziaWG 后端(可选,来自 amneziawg-tools) /etc/nginx/sites-available/xrayebator-sub ``` @@ -94,11 +102,14 @@ Reality 指纹是按配置档或路由保存的客户端值。更改它只会修 ```text https:///sub/<32-hex-token> # 443 上的公共 TLS https://:8443/sub/<32-hex-token> # 其他端口上的公共 TLS +http://:8080/sub/ # http_tls 回退(LE 无法验证 http-01) http://127.0.0.1:8080/sub/ # 仅本地回退 ``` 交互式 HAPP 设置可以选择公共端口,`_subscription_base_url` 会保留这一选择。非交互式的 -`quickstart --email
` 和 `quickstart --without-email` IP-TLS 流程会在 `8443` 配置 nginx、证书和标记,然后返回该 endpoint 的 `subscription_url`。不提供邮箱时,Certbot 会在没有 ACME 联系地址的情况下注册,因此无法接收续期通知或通过邮箱恢复。令牌以 `sub_token` 形式存储在配置档中;执行 revoke 会轮换令牌并使之前的 URL 失效,而完全吊销(`profile-revoke --full`)还会更换该配置档所有 inbound 中的 uuid,因此已下载的配置无法再连接。配置档有效期保存在 `expire`(epoch 秒)中,由 systemd 定时器 `xrayebator-expire.timer` 每 10 分钟通过 `xrayebator expire-check` 强制执行。只输入日期时,有效期包含服务器本地时区当天,至 `23:59:59` 到期;显式指定的时间也按服务器本地时区解释。`profiles` JSON 还会提供服务器时区中的 `expire_date`,GUI 无需按客户端本地时区从 epoch 时间戳反推出日期。强制执行不会删除入站:被停用的客户端保存在 `.expire_clients` 快照中,续期时原样恢复,因为重建入站会改变 Reality shortId 并使已发出的链接失效。 +`quickstart --email
` 和 `quickstart --without-email` IP-TLS 流程会在 `8443` 配置 nginx、证书和标记,然后返回该 endpoint 的 `subscription_url`。若 Let's Encrypt 无法验证 +http-01(服务商过滤 80 端口),quickstart 降级为 `http_tls`:标记切换为该模式,订阅以 HTTP 从 +loopback 处理器发布,JSON 携带 `degraded:true`;解除 80 端口封锁后重新部署即恢复 ip_tls。不提供邮箱时,Certbot 会在没有 ACME 联系地址的情况下注册,因此无法接收续期通知或通过邮箱恢复。令牌以 `sub_token` 形式存储在配置档中;执行 revoke 会轮换令牌并使之前的 URL 失效,而完全吊销(`profile-revoke --full`)还会更换该配置档所有 inbound 中的 uuid,因此已下载的配置无法再连接。配置档有效期保存在 `expire`(epoch 秒)中,由 systemd 定时器 `xrayebator-expire.timer` 每 10 分钟通过 `xrayebator expire-check` 强制执行。只输入日期时,有效期包含服务器本地时区当天,至 `23:59:59` 到期;显式指定的时间也按服务器本地时区解释。`profiles` JSON 还会提供服务器时区中的 `expire_date`,GUI 无需按客户端本地时区从 epoch 时间戳反推出日期。强制执行不会删除入站:被停用的客户端保存在 `.expire_clients` 快照中,续期时原样恢复,因为重建入站会改变 Reality shortId 并使已发出的链接失效。 新创建的标准托管 HAPP 配置档是 schema-v3 七路由配置档,包括 `xhttp-legacy` 和后量子 XHTTP 路由。 发布的 HAPP 连接列表包含六个 VLESS 路由,因为 PQ 路由仍可通过原始/配置档路径访问。助手也可能 @@ -107,7 +118,28 @@ http://127.0.0.1:8080/sub/ # 仅本地回退 处理器还提供由令牌保护的 `geoip.dat` 和 `geosite.dat` 资源,这些资源是托管的 HAPP 路由配置 所必需的。HAPP 接收其路由元数据,而 v2rayNG 和 v2rayN 接收兼容的 VLESS 主体,不包含 HAPP 专属 -元数据。 +元数据。当 Hysteria 2 后端已安装且配置档持有授权时,处理器会在 HAPP 与通用两个订阅主体中各追加 +一行 `hysteria2://`,由注册表标志 `sub_body` 控制;过期或停用的配置档不会得到该行。 + +## 多协议后端 + +除 Xray 外,管理器可以在同一台 VPS 上安装其他传输。所有后端状态位于中性根目录 +`/usr/local/etc/xrayebator/`(注册表 `backends.json` 与各后端目录);Xray 状态路径不受影响。 +每个配置档的后端凭据保存在其附加的 `.backends` 对象中,配置档 JSON 因此成为唯一事实来源: +服务端配置(`backends/hysteria2/server.yaml`、`backends/awg/awg0.conf`)始终由配置档重新生成, +配置档生命周期事件(创建、删除、吊销、过期、恢复)通过 `_backend_apply_profile_lifecycle` +传播到每个已安装的后端。 + +- **Hysteria 2** —— 高速 UDP 传输。独立 systemd 单元 `hysteria-server.service`,以专用 + `hysteria` 用户运行并持有 `CAP_NET_BIND_SERVICE`;默认 UDP 443;自适应 TLS(复用订阅的 + Let's Encrypt 证书并附续期 deploy-hook,或自签证书);`auth: userpass`,每个配置档一个凭据, + 映射可能为空时使用占位用户(Hysteria 拒绝空映射)。 +- **AmneziaWG 2.0** —— 系统级 VPN。通过 DKMS 的内核模块(首选 PPA `amnezia/ppa`,回退源码 + 编译),接口 `awg0` 走 `awg-quick@awg0`,网段 `10.8.1.0/24`,按配置档的 peer(密钥对 + + 预共享密钥 + 地址),`ip_forward` 与默认路由接口上的 MASQUERADE。发行版单元读取 + `/etc/amnezia/amneziawg/awg0.conf`——它是指向受管文件的 symlink。 +- 两个后端均为可选,通过菜单项 `11`–`13` 或 `hysteria2-*` / `awg-*` CLI 命令安装与卸载, + 默认绝不安装。 ## 更新路径 diff --git a/docs/zh-CN/configuration.md b/docs/zh-CN/configuration.md index 77ee730b..00f1f2bd 100644 --- a/docs/zh-CN/configuration.md +++ b/docs/zh-CN/configuration.md @@ -81,12 +81,16 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `4` | 管理配置档:SNI、指纹、端口、advanced | | `5` | 将单个配置档升级到 PQ XHTTP | | `6` | HAPP 订阅:7 条线路的配置档、public TLS、链接、二维码、吊销 | +| `7` | 分流路由(Bypass):选中域名(银行、电商、Steam 等)直连,不走隧道 | | `8` | 级联与上游节点 | | `9` | 自有域名与 self-steal 挡板 | | `10` | 部署出站服务器,使本 VPS 成为级联的境外节点 | +| `11` | Hysteria 2 后端:安装/状态/客户端链接/卸载(高速 UDP 传输) | +| `12` | AmneziaWG 2.0 后端:安装/状态/客户端配置/卸载(系统级 VPN) | +| `13` | 所有后端的统一状态 | | `0` | 退出 | -操作项从 `1` 到 `10` 连续编号;`0` 用于退出程序。 +操作项从 `1` 到 `13` 连续编号;`0` 用于退出程序。 端口和 SNI 是共享入站的设置,修改它们可能影响同一端口上的其他配置档。指纹是配置档/线路级别的客户端参数,修改它不会重启 Xray,也不会影响其他线路。任何修改之后,请在客户端强制刷新订阅,或通过 `3) Подключиться по профилю` 重新获取原始线路。 @@ -99,24 +103,38 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 | `sudo xrayebator update ` | 从规范 raw 仓库分支 self-update 管理器,继续使用新脚本,然后更新 Xray-core;分支写入 `.current_branch` 固定,供后续 GUI 更新使用 | | `sudo xrayebator probe-test` | 更换 SNI 前,从 VPS 检查其可达性 | | `sudo xrayebator quickstart --email <邮箱>` | 桌面 GUI 使用的一次性部署路径:执行广泛设置/迁移,在 `8443` 配置 IP-TLS endpoint,创建带 `schema_version: 3` 和 7 条线路的标准 HAPP 配置档;输出带 `subscription_url` 的 JSON。非交互迁移是 best-effort,请检查最终配置档与服务 | -| `sudo xrayebator quickstart --without-email` | 相同的一次性部署路径,但不提供 ACME 联系邮箱;Certbot 使用 `--register-unsafely-without-email`,因此没有续期通知或邮箱恢复 | +| `sudo xrayebator quickstart --without-email` | 相同的一次性部署路径,但不提供 ACME 联系邮箱;Certbot 使用 `--register-unsafely-without-email`,因此没有续期通知或邮箱恢复。若 Let's Encrypt 无法通过 http-01 验证 IP(validation 主机被服务商在 80 端口上游拦截),部署不会失败而是降级为 `http_tls`:订阅以 HTTP 发布,JSON 携带 `degraded:true`,解除 80 端口封锁后重新部署即恢复 HTTPS | | `sudo xrayebator inspect --json` | GUI 导入时使用的只读安装检查:返回 Xray、配置档和订阅标记状态;不会安装、迁移或修改配置 | | `sudo xrayebator happ-setup` | 已有安装的精简 HAPP 路径:确保订阅服务和可用的多线路配置档;缺少订阅域或端口标记时,会先验证 `8443` 的产品 IP-TLS endpoint,否则失败 | +| `quickstart` http_tls 模式 | 若 Let's Encrypt 无法对 IP 执行 http-01 验证(validation 主机报 `Connection reset by peer`——通常是服务商对境外来源过滤 80 端口),部署不失败:订阅保持仅回环访问(完全不存在公共 URL——GUI 通过 SSH 从服务器本机的 `http://127.0.0.1:8080/sub/` 加载密钥),并为后续后端生成自签证书;结果 JSON 携带 `degraded:true`、`tls_mode:"http_tls"` 与 Certbot 原因。服务器以「配置不完整」并带降级标记保存;80 端口上的 ACME challenge location 保持原位——解除封锁后重新运行 quickstart 即签发 LE 证书并把订阅切回 HTTPS | | `sudo xrayebator profiles` | 以 JSON 数组输出服务器全部配置档(供桌面 GUI「服务器设置」页使用);有效期同时包含用于服务端执行的 epoch 秒(`expire`)和用于界面显示的服务器本地日历日期(`expire_date`),因此不同时区的客户端也会显示用户选择的日期 | -| `sudo xrayebator profile-create --name 名称 [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire 日期]` | 非交互式创建单个或多个配置档;`--expire` 接受 `YYYY-MM-DD[ HH:MM]`、epoch 秒或 13 位毫秒。只选日期时,配置档在服务器本地时区的当天 `23:59:59` 之前(含该秒)有效;指定时间时按同一服务器时区执行。过去的有效期会被拒绝,打印 `{"ok":true,"names":[...],"errors":[...]}` | -| `sudo xrayebator profile-delete --name 名称` | 非交互式删除配置档,打印 `{"ok":true,"name":"..."}` | +| `sudo xrayebator profile-create --name 名称 [--transport tcp\|tcp-utls\|tcp-xudp\|tcp-mux\|grpc\|xhttp] [--port P] [--count N] [--expire 日期]` | 非交互式创建单个或多个配置档;`--expire` 接受 `YYYY-MM-DD[ HH:MM]`、epoch 秒或 13 位毫秒。只选日期时,配置档在服务器本地时区的当天 `23:59:59` 之前(含该秒)有效;指定时间时按同一服务器时区执行。过去的有效期会被拒绝;已有名称不会被覆盖——它们会进入 `errors`(GUI 在创建前也会预检名称冲突,因为删除以配置档为单位,名称唯一才能保证删除精确),打印 `{"ok":true,"names":[...],"errors":[...]}` | +| `sudo xrayebator profile-delete --name 名称` | 非交互式删除配置档;被删配置档的后端授权会立即吊销——AWG peer 从 `awg0.conf` 移除,Hysteria 密码从 `server.yaml` 移除(不会残留孤立凭据),名称可立即复用;打印 `{"ok":true,"name":"..."}` | | `sudo xrayebator profile-revoke --name 名称 [--full]` | 重新签发订阅链接:新的 `sub_token`;带 `--full` 时还会更换该配置档所有 inbound 中的 uuid(已下载的配置立即失效),打印 JSON | | `sudo xrayebator profile-expire --name 名称 --expire 日期\|epoch\|none` | 设置、延长或取消配置档有效期;只输入日期时,在服务器本地时区的当天 `23:59:59`(含)到期,显式输入的时间按同一时区原样使用;立即生效(已过期会移除客户端,延长则恢复),打印 JSON | | `sudo xrayebator expire-check` | 批量应用所有已到期的有效期;幂等,无变化时不重启 Xray。由 `xrayebator-expire.timer` 每 10 分钟触发 | | `sudo xrayebator fp-change --name 名称 [--route R] --fp 指纹` | 修改配置档的指纹,打印 JSON 结果 | | `sudo xrayebator sni-change --name 名称 [--route R] --sni SNI` | 修改配置档的 SNI,并同步更新同一端口上的所有配置档,打印 JSON 结果 | -| `sudo xrayebator sni-list` | 按类别列出 `sni_list.txt` 中的候选 SNI,打印 JSON 结果(供桌面 GUI 的 SNI 对话框使用) | +| `sudo xrayebator sni-list` | 按类别列出 `sni_list.txt` 中的候选 SNI,打印 JSON 结果(供桌面 GUI 的 SNI 对话框使用)。`sni_list.txt` 中第一条非注释行是新配置档的默认 SNI(`www.cloudflare.com`) | | `sudo xrayebator port-change --name 名称 [--route R] --port 端口\|random` | 修改配置档的端口;更新入站、防火墙与订阅。客户端需要重新连接,打印 JSON 结果 | | `sudo xrayebator bypass list` | 按分组列出当前分流规则(JSON) | | `sudo xrayebator bypass add --domain D` | 向分流规则添加一个域名(JSON) | | `sudo xrayebator bypass remove --domain D` | 从分流规则移除一个域名(JSON) | | `sudo xrayebator bypass reset` | 清空所有自定义分流规则(JSON) | | `sudo xrayebator bypass bundle [--group a,b,c]` | 应用默认分流分组;不带 `--group` 时重新应用全部分组(JSON) | +| `sudo xrayebator backend-status` | 以 JSON 输出多协议后端注册表状态(`{"ok":true,"backends":{…}}`) | +| `sudo xrayebator hysteria2-install [--port P] [--grant-all]` | 安装 Hysteria 2 UDP 后端:从 `HyNetworks/hysteria` 官方 release 下载二进制(SHA-256 校验)、专用 `hysteria` 服务账户、独立 systemd 单元(`CAP_NET_BIND_SERVICE`)、QUIC sysctl 缓冲、自适应 TLS(优先复用订阅的 Let's Encrypt 证书并附续期 deploy-hook,否则自签);UDP 端口默认 443;输出 JSON(已安装时 `already: true`) | +| `sudo xrayebator hysteria2-uninstall` | 移除 Hysteria 2 后端(服务、配置、证书、防火墙规则);JSON | +| `sudo xrayebator hysteria2-status` | Hysteria 2 后端状态(JSON) | +| `sudo xrayebator hysteria2-grant --name N` | 为配置档签发 Hysteria 2 凭据(存入配置档的 `.backends.hysteria2`);服务端配置按全部配置档重新生成;JSON | +| `sudo xrayebator hysteria2-subbody --on\|--off` | 两个订阅主体中 `hysteria2://` 行的总开关:标志存于后端注册表,处理器每次请求都会重读,无需重启服务;JSON | +| `sudo xrayebator hysteria2-link --name N` | 以 JSON `{ok, name, link}` 输出持有授权配置档的 `hysteria2://` 链接(命名与 VLESS 路由一致,带国家旗帜) | +| `sudo xrayebator awg-install [--grant-all]` | 安装 AmneziaWG 2.0 系统级 VPN 后端:DKMS 内核模块(首选 `amnezia/ppa` PPA,回退源码编译)、`awg0` 接口与随机高位 UDP 端口、junk 参数与 Amnezia 默认值一致、`ip_forward` + MASQUERADE;JSON | +| `sudo xrayebator awg-uninstall` | 移除 AmneziaWG 后端(接口、配置、symlink、防火墙规则;软件包与模块保留在系统中);JSON | +| `sudo xrayebator awg-status` | AmneziaWG 后端状态(JSON) | +| `sudo xrayebator awg-grant --name N` | 为配置档签发 peer(密钥对 + 预共享密钥 + `10.8.1.x` 地址,存入 `.backends.awg`);重新生成 `awg0.conf`;JSON | +| `sudo xrayebator awg-conf --name N` | 输出配置档 peer 的客户端 `.conf`(JSON `{ok, name, conf}`)——全隧道 AllowedIPs、服务端 junk 参数、endpoint;导入 AmneziaWG/AmneziaVPN 客户端 | +| `sudo xrayebator awg-31 --on\|--off` | 切换 AWG 3.1 配置格式(`HeaderProtectionKey`、`RandomTrailers`、`S3`/`S4`);新安装默认开启 3.1,切换会在原地补齐缺失的服务端参数,警告所有已签发的客户端 `.conf` 必须重新下载(AmneziaVPN ≥ 5.0.1.5),并带回滚地重新生成接口 | | `sudo xrayebator-update [branch]` | 运行完整的 `update.sh` 生命周期更新;无参数时显示 `.current_branch` 并打开交互式分支选择,有参数时使用该分支 | | `sudo xrayebator-uninstall` | 移除服务与配置 | @@ -141,8 +159,8 @@ Xrayebator 不会更改主机的 TCP 拥塞控制算法,也不会写入或应 |---|---| | Dashboard | 服务器卡片、连通性检查、打开/设置/删除、语言切换 | | 添加服务器 | 通过 SSH 完整部署,带步骤进度:`os check → upload → install → binary → quickstart` | -| 服务器密钥 | 刷新订阅、复制链接、显示 `vless://` 链接与二维码 | -| 服务器设置 | 使用 SSH 密码或私钥,并选择直接 root 或 sudo:列出/创建/删除配置、`fp-change`、`sni-change`、`port-change`,以及更新/卸载服务器 | +| 服务器密钥 | 刷新订阅、复制链接、显示 `vless://` 链接与二维码;多协议后端密钥(Hysteria 2 链接、AmneziaWG 客户端配置,文本+二维码) | +| 服务器设置 | 使用 SSH 密码或私钥,并选择直接 root 或 sudo:列出/创建/删除配置、`fp-change`、`sni-change`、`port-change`,多协议后端面板(安装/卸载、按配置档密钥、订阅开关、AWG 3.1 切换),以及更新/卸载服务器 | 界面语言(Русский / English / 简体中文)在 Dashboard 页眉切换,并保存在 `localStorage` 的 `xrayebator-language` 键中。构建与运行: @@ -155,7 +173,9 @@ npm test # Vitest 单元测试 npm run typecheck # TypeScript 检查 ``` +## 分流路由(Bypass) +分流路由让选中的域名绕过隧道:匹配的流量直连(direct),其余流量继续走 VPN。 `domain -> direct` 规则位于兜底规则之上,因此在启用级联时仍然生效。 默认组合包中的分组: @@ -172,6 +192,150 @@ npm run typecheck # TypeScript 检查 菜单是交互式的:方向键移动光标,空格切换分组,回车应用。 +## 多协议后端 + +除 Xray Reality 外,同一台 VPS 还可以运行由同一 CLI/菜单管理的其他传输。所有后端状态位于 +中性根目录(不触碰 `/usr/local/etc/xray/`): + +```text +/usr/local/etc/xrayebator/ +├── backends.json # 注册表:各后端的 installed/version/port/开关(644,无机密) +└── backends/ + ├── hysteria2/ # server.yaml、证书、占位凭据 + └── awg/ # awg0.conf、server-params.json(0600) +``` + +每个配置档的后端凭据保存在配置档 JSON 自身(`.backends.hysteria2`、`.backends.awg`)——配置档 +是唯一事实来源,后端服务端配置始终由配置档重新生成。配置档生命周期事件(创建/删除/吊销/过期/ +恢复)会自动传播到每个已安装的后端:吊销会轮换 Hysteria 密码与 AWG peer 密钥;过期会从两个 +服务端配置中移除授权;续期则恢复两者。 + +### Hysteria 2(高速 UDP) + +`hysteria2-install` 从官方 `HyNetworks/hysteria` release 下载二进制(SHA-256 校验),创建 +`hysteria` 服务账户与独立 systemd 单元,默认监听 UDP 443(与 TCP 443 的 Reality 入站并存)。 +TLS 自适应:订阅 endpoint 已有 Let's Encrypt 证书时直接复用(客户端 `insecure=0`),否则生成 +自签证书(`insecure=1`)。尚无配置档授权时,auth 映射携带 `_xrayebator_placeholder` 用户—— +Hysteria 拒绝空的 userpass 映射。客户端链接形如 +`hysteria2://user:pass@host:port/?sni=…&insecure=0|1#name`,会追加到两个订阅主体(HAPP 与通用); +注册表标志 `sub_body` 是客户端解析器出问题时的总开关。 + +### AmneziaWG 2.0(系统级 VPN) + +`awg-install` 通过 DKMS 编译内核模块(首选 `amnezia/ppa` PPA;回退为源码编译,内核 ≥ 5.6 需要 +完整 `linux-source`),并通过 `awg-quick@awg0` 拉起 `awg0` 接口:随机高位 UDP 端口、网段 +`10.8.1.0/24`、`ip_forward` 与默认路由接口上的 MASQUERADE。junk 参数与 Amnezia 默认值一致—— +这是所有官方 AmneziaVPN 客户端(手机与桌面)都能可靠应用的方言:`Jc` 5、`Jmin` 10、`Jmax` 50、 +`H1`–`H4` = 1..4(WireGuard 魔数)、`S1`/`S2` 随机 12..150、`S3`/`S4` 随机 12..64 +(均互不相同,`S1+56 ≠ S2`)。每个配置档 peer 获得密钥对、预共享密钥与首个空闲地址;客户端 `.conf`(菜单项 12 或 +`awg-conf --name N`)携带全隧道 `AllowedIPs` 与服务端 junk 参数,开头带有自述性头部:该配置档 +通过 AmneziaVPN/AmneziaWG 运行,而非 V2Ray 客户端(HAPP)。桌面 GUI 中,相同的密钥通过 +Server Settings 的「密钥」按钮按配置档交付:Hysteria 2 链接与 AWG `.conf` 以文本+二维码呈现, +并支持一键签发授权。「QR · AmneziaVPN」二维码以应用原生 `vpn://` 形态携带配置(`amnezia-awg2` +容器、容器级服务器 junk 字段、`protocol_version`、压缩 payload)——直接把裸 `.conf` 粘进 +AmneziaVPN 无法完整到达其隧道,因此该应用推荐使用此码;普通二维码保留给独立版 AmneziaWG +客户端。peer 变更会带回滚地重启接口 +——所有 peer 的隧道会短暂中断;授权与吊销是低频操作,第一阶段可接受。 + +### AWG 2.0 与 3.x 的差异——以及 3.1 为何对 DPI/ТСПУ 更重要 + +按上游协议的划分,AmneziaWG 参数分为两组: + +| 服务端与客户端必须逐字节一致 | 各端本地设置 | +|---|---| +| `S1`–`S4`、`H1`–`H4` | `PersistentKeepalive`(建议 22–30) | +| 3.0:`HeaderProtectionKey` | 3.0:`ContentPaddingAddition`、`Rekey*`、`Reject*`、`Keepalive*`、`MaxHandshakeAttempts`(整数或 `"a-b"` 区间) | +| 3.1:`RandomTrailers` | 3.1:`DisableCookies` | + +Xrayebator 实现了 AWG 3.1 特性集:junk 参数加 `S1`–`S4`(≥ 12)、随机生成的 `HeaderProtectionKey` +(服务器与每个客户端 `.conf` 共享),以及 `RandomTrailers = on`。`DisableCookies` 可用但默认关闭 +(代价是失去内建的防放大防御)。新安装直接获得 3.1;3.1 阶段之前的安装可通过菜单项 12 → +「Режим AWG 3.1」或 `awg-31 --on|--off` 补齐缺失密钥、切换配置格式并重新生成接口。迁移警示依然 +成立:3.1 键位于「必须一致」组,切换后**所有先前发出的客户端 `.conf` 必须重新下载**,且客户端 +需要 AmneziaVPN ≥ 5.0.1.5——旧版本会直接拒绝导入。用 `awg show` 验证:没有 `latest handshake` +说明「必须一致」组不一致;有握手无流量则去查 `awg-quick`/路由/防火墙。 + +### HAPP 客户端路由配置 + +订阅端点通过响应头 `routing:` 向 HAPP 下发客户端路由配置。托管的默认配置 `xrayebator-default` +设置了 `GlobalProxy: "true"`,因此除私有 IPv4 段之外的所有目标都会走隧道。 + +这与[分流路由](#分流路由)不是同一个开关。分流路由改变的是**服务端**把请求发往何处;客户端仍然会把它 +送进隧道,目标站点看到的依旧是 VPS 的地址。在没有级联的节点上,兜底 outbound 本来就是 `direct`, +所以分流路由无法改变俄罗斯站点看到的结果。只有客户端配置才能让本国流量不进入隧道。 + +配置通过响应头下发给客户端,因此较大的 `DirectSites` 列表可能超出 nginx 默认的 4k 代理缓冲区。生成的订阅 +nginx 配置已调高该值(`proxy_buffer_size 32k`),域名模式的 `location /sub/` 与 IP 模式(quickstart, +`location /`)均已覆盖;如果订阅前面还有你自己的反向代理,也需要在那里 +调高 `proxy_buffer_size`,否则 nginx 会返回 502 并在日志中记录 `upstream sent too big header`。 + +### 从菜单生成 + +`HAPP 订阅 → 7) 客户端路由` 会写入一份现成的分流配置,其内容取自服务端分流路由使用的同一批域名集合。 +`GlobalProxy` 保持为 `"true"`,未知目标仍走隧道,而俄罗斯相关集合与 `geoip:ru` 会移入 `DirectSites` +和 `DirectIp`。原有的覆盖文件会备份在同一目录,删除覆盖文件即可恢复托管的默认配置。配置使用占位符 +(`{{GEOIP_URL}}`、`{{GEOSITE_URL}}`、`{{LAST_UPDATED}}`),在请求时按订阅者解析——共享文件中 +不包含任何 token。 + +### 覆盖文件 + +| 变量 | 默认值 | 含义 | +|---|---|---| +| `HAPP_ROUTING_ENABLED` | `true` | 设为 `false` 则不再下发 `routing:` 响应头 | +| `HAPP_ROUTING_JSON_FILE` | `/usr/local/etc/xray/.happ_routing.json` | 运维方的覆盖文件 | + +如果覆盖文件存在并通过 HAPP 结构校验,它会对所有订阅者取代托管的默认配置。格式错误的 JSON 会被忽略 +并回退到默认配置,而不会下发给客户端。无需重启 `xrayebator-sub.service`:该文件在每次请求时读取。 + +判定是按目标逐个进行的。`GlobalProxy: "false"` 时默认行为是 `direct`,只有 `ProxySites` 和 +`ProxyIp` 走隧道;`GlobalProxy: "true"` 则相反,`DirectSites` 和 `DirectIp` 成为例外。 + +### 占位符 + +覆盖文件在每次请求时读取,因此可以把三个值交给服务端填充: + +| 占位符 | 替换为 | +|---|---| +| `{{GEOIP_URL}}` | 当前订阅者的 `<基础地址>/sub//geoip.dat` | +| `{{GEOSITE_URL}}` | 当前订阅者的 `<基础地址>/sub//geosite.dat` | +| `{{LAST_UPDATED}}` | `xrayebator` 与两个 geo 数据库中最新的修改时间 | + +替换在结构校验之前进行,因此未被解析的占位符会无法通过 `https://` 校验,客户端将收到托管的默认配置, +而不是一份损坏的配置。`{{LAST_UPDATED}}` 还省去了修改 geo 数据库后手动递增该值的麻烦。 + +三个容易出错的地方: + +- **Telegram 按 IP 地址通信,而不是域名。** 域名规则匹配不到它。请从 + 获取网段,不要凭记忆填写:该列表会变化,遗漏一个 + 网段会悄悄拖慢媒体下载,而聊天看起来仍然正常。 +- **`LastUpdated` 必须递增。** 只有当该值大于已保存的值时,HAPP 才会重新导入配置。 +- **geo 数据库必须能被客户端访问。** 托管的默认配置指向 `/sub//geoip.dat`,该路径与具体订阅者 + 绑定。上面的占位符解决了这个问题;否则请把数据库放在任何客户端都能下载的位置。 +- **数据库 URL 里的国家可能标错。** 各家的 ASN 地理标注并不一致:ipinfo.io 与 ipwho.is/ip-api 会把 + 同一网段划到不同国家(例如 NODE HOST LIMITED:芬兰 vs 德国/法兰克福)。托管的 HAPP 配置让客户端 + 通过同一订阅的 `{{GEOIP_URL}}`/`{{GEOSITE_URL}}` 下载 geo 数据库,因此在 http_tls 回退(公共订阅 + 不可用)时,没有预载数据库的客户端无法下载,HAPP 会显示「无数据」。解决方法:手工修正 + `/usr/local/etc/xray/.server_country`(`代码|国家|城市`)并通过 SSH 导入密钥,或恢复公共订阅。 + +客户端下载新的 geo 数据库期间,先前的配置继续生效,因此下载失败只会保持路由不变,而不会使其损坏。 + +### 一个密钥——多少台设备? + +VLESS、Hysteria 2 与 AmneziaWG 的多设备语义不同: + +- **VLESS(订阅)**——订阅发放配置档的 UUID,而 UUID 并非独占:任意数量的设备都可以在 HAPP 中 + 拉取同一订阅并同时连接,各自按线路建立自己的 Reality 隧道。线路是传输方式,不是设备槽位。 +- **Hysteria 2**——按连接认证(`userpass`),服务器不会把凭据锁定到单一会话:**一条 + `hysteria2://` 链接可供不限数量的设备同时使用**,每台设备拥有独立的 QUIC 会话。它们只共享 + 服务器带宽。 +- **AmneziaWG**——cryptokey 路由将 peer 绑定到其密钥对,接口上每个 peer 只保留一个 endpoint + (最后认证者胜出)。**一个密钥 = 一台设备才可靠。** 两台设备用同一 `.conf` 会争抢 endpoint + 并轮流重连;同一 NAT 后勉强能用,跨网络会散架。第二台设备需要自己的配置档/密钥——它会得到 + 自己的 peer 与 `10.8.1.x` 地址。 +- **nginx** 只在 8443 上服务订阅端点(密钥交付)。Hysteria(UDP 443)与 AmneziaWG(各自 UDP + 端口)完全绕过 nginx,后端之间也没有任何负载均衡——它们是独立端口上的独立服务;每台设备只是 + 向所用协议添加自己的会话。 + ## 级联与上游节点 级联是服务端的出站与路由模式,而不是新的客户端配置档。客户端仍然连接当前 VPS: diff --git a/docs/zh-CN/desktop-gui.md b/docs/zh-CN/desktop-gui.md index cd396968..3fb4aaee 100644 --- a/docs/zh-CN/desktop-gui.md +++ b/docs/zh-CN/desktop-gui.md @@ -44,20 +44,22 @@ GUI 会显示部署日志和步骤状态,但进行中的部署没有 IPC 取 向导显示步骤索引和实际执行工作的实时控制台:SSH 连接、`xrayebator inspect --json` 调用、返回的组件状态、订阅探测和最终结果。订阅 URL 是 bearer credential,因此其令牌在进入控制台前会被遮蔽(`…`);密码和密钥字节完全不会出现在其中。 ### Server keys -Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 VLESS 线路。每条 VLESS 链接都可以复制或生成二维码;订阅 URL 也可以复制,页面还提供复制全部内容的操作。此页面不会在服务器上创建独立订阅,也不会轮换订阅令牌(轮换在 Server settings 的配置档卡片上进行)。 +Server keys 会从保存的 `subscription_url` 刷新订阅,并显示返回的 VLESS 线路。每条 VLESS 链接都可以复制或生成二维码;订阅 URL 也可以复制,页面还提供复制全部内容的操作。此页面不会在服务器上创建独立订阅,也不会轮换订阅令牌(轮换在 Server settings 的配置档卡片上进行)。当服务器存在多协议后端时,页面还会从持久化本地存储中显示它们的密钥:每个持有授权的配置档一张 Hysteria 2 卡片(`hysteria2://` 链接,复制+二维码;卡片芯片标注所属配置档名称:`HYSTERIA2 · <配置档> · UDP :<端口>`),以及一张 AmneziaWG 卡片——客户端 `.conf`,其中 `Endpoint`/`Address` 以键值字段呈现,去掉服务注释头的完整 `.conf` 以文本和二维码提供,可导入 AmneziaVPN/AmneziaWG。后端卡片立即从持久化存储渲染,并与 VLESS 线路并行刷新;spinner 位于卡片内部,仅在数据获取期间可见。AWG 卡片有两个二维码:普通二维码用于独立版 AmneziaWG 客户端,「QR · AmneziaVPN」则是应用原生压缩 `vpn://` 形态(`amnezia-awg2` 容器、服务器级 junk 字段、`protocol_version` 3.1),是完整版 AmneziaVPN 的推荐导入路径。 ### Server settings Server settings 先通过 SSH 认证。如果卡片中已有系统钥匙串保存的 SSH 密码或持久化私钥,页面会自动连接并只显示配置档面板;仅当没有已保存的凭据或连接失败时才显示访问表单。访问摘要与“更改访问方式”操作位于 Dashboard 的服务器卡片上,而不在配置档页面内。连接后可以: - 列出已有配置档; -- 创建一个或多个配置档并删除配置档; -- 选择 `xhttp`、`tcp`、`tcp-utls`、`tcp-xudp`、`tcp-mux` 或 `grpc` 传输; +- 创建一个或多个配置档并删除配置档(配置档名称唯一:GUI 在创建前预检名称冲突——删除以配置档为单位,名称唯一才能保证删除精确); +- 选择 `xhttp`、`tcp`、`tcp-utls`、`tcp-xudp`、`tcp-mux` 或 `grpc` 传输——或选择两个多协议后端之一(传输网格下方的 Hysteria 2 / AmneziaWG 3.1 卡片)。点击后端卡片只是选择协议(选择 VLESS 传输会取消后端选择,反之亦然);配置档由「创建配置档」按钮创建,且只有所选后端会签发密钥——底层的 VLESS 配置档使用推荐的 `xhttp` 传输; - 修改配置档 fingerprint,从 `sni-list` 选择 SNI,或手动输入 SNI; - 修改配置档线路的端口,或选择随机端口; -- 更新服务器安装; +- 更新服务器安装(分支菜单:「按服务器上固定的分支」——`.current_branch`,否则 main;**main 是最新的稳定版本**;**dev 是包含全部新功能的最新版本,但可能不稳定**;从 dev 切回只需更新到 main); - 确认后卸载服务器安装; -- 在明确确认后重置固定的 SSH host key。 +- 在明确确认后重置固定的 SSH host key; +- 管理多协议后端(与菜单项 11–13 对齐):合并面板「补充协议」(正在开发中——界面和行为可能变化)显示 Hysteria 2 与 AmneziaWG 状态(版本、端口、TLS 模式 / 接口、junk 摘要),提供安装/卸载、订阅中 `hysteria2://` 行的总开关和 AWG 3.1 切换——后两者均带明确确认; +- 配置档列表中每个配置档的后端卡片:标题为配置档名称,副标题为协议,绿色状态行(「已激活 · SNI …」或「已激活 · UDP <端口>」);操作有二维码(AmneziaWG 另有「QR · AmneziaVPN」)、有效期、Revoke 和删除。删除配置档会立即吊销其后端密钥——AWG peer 从 `awg0.conf` 移除,Hysteria 密码从 `server.yaml` 移除(不残留孤立凭据),名称立即可复用。完整复制功能只在 Server keys 页面。 SNI 和端口属于 inbound 级别的设置:修改它们可能影响共享该 inbound 的所有配置档。Fingerprint 则不同:它是按配置档/线路保存的客户端参数,不会修改其他线路。服务端命令会报告结果以及是否需要重新连接。 @@ -92,6 +94,14 @@ xrayebator port-change --name NAME [--route R] --port PORT|random xrayebator bypass list|add --domain D|remove --domain D|reset|bundle [--group a,b,c] ``` +后端管理会额外调用: + +```text +xrayebator backend-status +xrayebator hysteria2-install [--port P] [--grant-all] / hysteria2-uninstall / hysteria2-grant --name N / hysteria2-subbody --on|--off / hysteria2-link --name N +xrayebator awg-install [--grant-all] / awg-uninstall / awg-grant --name N / awg-conf --name N / awg-31 --on|--off +``` + 部署流程会调用以下命令之一: ```text diff --git a/docs/zh-CN/security.md b/docs/zh-CN/security.md index 7d442dda..c4c4d0f4 100644 --- a/docs/zh-CN/security.md +++ b/docs/zh-CN/security.md @@ -57,6 +57,25 @@ Xray 以系统用户 `xray` 运行。Drop-in - 不要把 local-only URL 交给外部客户端; - 没有理解 nginx 配置前,不要在同一域名上托管第三方面板或代理。 +## 多协议后端安全 + +可选后端(Hysteria 2、AmneziaWG)遵循与 Xray 服务相同的信任模型,并按后端细化: + +| 路径或类别 | 属主与权限 | +|---|---| +| `/usr/local/etc/xrayebator/backends.json` | `root:root 644` —— 刻意不含机密(只有端口、版本、开关);`xray` 服务账户在订阅处理器中读取它 | +| `/usr/local/etc/xrayebator/backends/hysteria/` | `root:hysteria 750`;`server.yaml` 与证书 `0640 root:hysteria` —— 服务必须能读,其他任何人不触碰 | +| `/usr/local/etc/xrayebator/backends/awg/` | `root:root 700`;`awg0.conf` 与 `server-params.json` 为 `0600` —— 服务器私钥与 peer 机密 | +| Hysteria 占位凭据 | `_xrayebator_placeholder`,随机密码存于 `0600` 文件;仅用于避免空 auth 映射,不签发给任何客户端 | +| certbot 续期 deploy-hook | `/etc/letsencrypt/renewal-hooks/deploy/xrayebator-hysteria.sh`,root `0755`;把续期后的证书复制进后端目录并重启后端 | +| `/etc/amnezia/amneziawg/awg0.conf` | 指向受管文件的 symlink —— 发行版 `awg-quick@awg0` 单元经它读取;不存在第二份配置副本 | + +Hysteria 以专用用户 `hysteria` 运行,带 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 与 +`NoNewPrivileges=true`;QUIC 缓冲 sysctl 位于 `/etc/sysctl.d/99-xrayebator-hysteria.conf`。 +AmneziaWG 需要 `net.ipv4.ip_forward=1`(独立 sysctl 文件),并通过接口配置的 +`PostUp`/`PostDown` 添加作用于后端子网的 MASQUERADE/FORWARD 规则。后端仅在操作者显式操作时 +安装,通过对应 `*-uninstall` 命令移除;`uninstall.sh` 不参与此流程。 + ## VPS 的 SSH 访问 Xrayebator 可以直接以 `root` 安装,但更好的做法是使用权限范围受限的独立用户。 diff --git a/docs/zh-CN/testing.md b/docs/zh-CN/testing.md index be1ff015..31fb7c7e 100644 --- a/docs/zh-CN/testing.md +++ b/docs/zh-CN/testing.md @@ -15,7 +15,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done ## 测试覆盖范围 -`validation/` 中有 28 个静态与本地回归测试: +`validation/` 中有 34 个静态与本地回归测试: | 测试 | 检查内容 | |---|---| @@ -23,7 +23,9 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `test-project-update-rollback.sh` | 项目更新失败后的回滚 | | `test-xhttp-route-path-repair.sh` | 迁移过程中 XHTTP 路径的修复 | | `test-multiroute-argument-preservation.sh` | 多线路配置档传输参数的保留 | -| `test-happ-subscription-static.sh` | HAPP 订阅处理器 | +| `test-happ-subscription-static.sh` | HAPP 订阅处理器(routing 占位符、校验顺序) | +| `test-happ-client-routing.sh` | HAPP 客户端分流配置:基于 bypass 集合的生成器(258 个域名、占位符、`geoip:ru`)、nginx 缓冲区、菜单接线 | +| `test-legacy-profile-port-sync.sh` | 无 `routes` 的旧版配置档仍能收到 SNI/端口同步 | | `test-subscription-server-name.sh` | 客户端中显示的订阅服务器名 | | `test-fingerprint-subscription-sync.sh` | 更换指纹时线路与订阅的同步 | | `test-dead-stealth-route-pruning.sh` | 失效 stealth 线路的清理 | @@ -35,7 +37,10 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `test-installer-network-fallbacks.sh` | 安装脚本网络回退 | | `test-bbr-removal-migration.sh` | 已删除 BBR/TCP 调优在所有路径上的安全清理 | | `test-legacy-udp443-migration.sh` | 一次性清理旧版 UDP/443 阻断规则 | -| `test-main-menu-numbering.sh` | 主菜单条目编号连续并与处理函数一致 | +| `test-main-menu-numbering.sh` | 主菜单条目编号连续并与处理函数一致(含后端的 1..13) | +| `test-backend-registry.sh` | 后端注册表(多协议阶段):source-mode 下的注册表 CRUD(经 `safe_jq_write`)、installed/field 读取、生命周期入口的安全 no-op、`backend-status` JSON 结构 | +| `test-hysteria2-lifecycle.sh` | Hysteria 2 后端:`server.yaml` 渲染(userpass 映射、占位回退)、systemd 单元安全模型、架构映射、TLS 探测、授权/吊销/过期-恢复事件、预删除配置档后的 deleted 重生成、created 为无操作(不自动签发)、纯函数 `hysteria2://` 构造器、CLI 分发与订阅接线 | +| `test-awg-lifecycle.sh` | AmneziaWG 后端:Amnezia 默认 junk 方言(Jc=5、Jmin=10、Jmax=50、H1–H4=1..4、S1–S4 互不相同)、密钥生成、服务端配置渲染(PostUp MASQUERADE、安装时无 peer)、peer 地址分配、客户端 `.conf` 内容、revoke/expire/restore 事件、预删除配置档后的 deleted 重生成、created 为无操作(不自动签发)、CLI 分发 | | `test-main-readiness-regressions.sh` | 审计后的 readiness 回归:certbot manifest、UFW manifest、nginx 回滚、权限与 SSH 端口 | | `test-sni-change-cli.sh` | `sni-change` CLI:JSON 输出、Reality、XHTTP host、配置档同步与回滚 | | `test-bypass-cli.sh` | `bypass` CLI:JSON 输出、路由规则更新、带 SNI 探测的 add | @@ -45,6 +50,7 @@ for test_file in validation/*.sh; do bash "$test_file" || exit; done | `test-quickstart-email-and-inspect.sh` | `quickstart` 的显式 email 模式(`--without-email` 不使用虚假地址)以及 `inspect --json` 的只读不变量 | | `test-quickstart-migration-parity.sh` | `quickstart` 执行与 `main_menu` 相同的关键迁移 | | `test-quickstart-subscription-port.sh` | 确认 `quickstart` 使用规范的订阅基础地址 helper,不回退到无关的硬编码 URL | +| `test-quickstart-tls-fallback.sh` | `http_tls` 优雅降级分支:SUB_TLS_MODE 切换、HTTP-only vhost(无 ssl/无 proxy)、按模式写入标记、仅 ip_tls 设置续期定时器以及 JSON 的 degraded 元数据 | | `test-audit-functional.sh` | HowDeploy 审计(P0/P1)的功能回归检查 | | `test-audit-privilege-regressions.sh` | 权限边界回归 | @@ -102,7 +108,7 @@ npm test # Vitest 单元测试 三个独立 workflow: -- **ci-linux.yml** — Bash validation:在 ubuntu-24.04 上对所有脚本执行 `bash -n`,并运行全部 28 个 +- **ci-linux.yml** — Bash validation:在 ubuntu-24.04 上对所有脚本执行 `bash -n`,并运行全部 34 个 `validation/test-*.sh`;在 push 到 `main`、`dev`、`experimental` 以及 pull request 时运行。 - **release.yml** — Electron 构建(Windows/macOS/Linux)。只在 `v*` tag 和手动触发时运行;先由 `preflight` 检查发布说明 `docs/releases/.en.md` 是否存在,以及仓库密钥 `AP3X0`(若已设置) diff --git a/docs/zh-CN/troubleshooting.md b/docs/zh-CN/troubleshooting.md index 1de45888..2cd9314e 100644 --- a/docs/zh-CN/troubleshooting.md +++ b/docs/zh-CN/troubleshooting.md @@ -155,10 +155,99 @@ sudo systemctl restart sshd 多个配置档可以同时使用:不同的订阅、SNI、端口和线路提供了更多绕过封锁的选择,但它们共享同一台 VPS 的资源。 +## Hysteria 2 服务无法启动 + +先读原因——安装会回滚产物,状态需要复现: + +```bash +journalctl -u hysteria-server.service --no-pager | tail -20 +``` + +已知失败模式(生成器已修复,手工改配置时仍然相关): + +- `invalid config: auth.userpass: empty auth userpass` —— auth 映射的键是 `userpass` 而**不是** + `users`,且空映射会被拒绝。生成器始终至少渲染 `_xrayebator_placeholder`(尚无配置档授权时); + 手工编辑 `server.yaml` 时,请保持 `userpass:` 下有非空映射。 +- `Changing to the requested working directory failed: Permission denied` —— 后端目录必须为 + `root:hysteria 0750`;只 `chmod` 而不做配对 `chown` 会留下 `root:root`,单元无法进入目录。 +- 证书不可读 —— `le` 模式下证书是 `root:hysteria 0640` 的副本;续期 deploy-hook 负责刷新。 + +修复后的干净路径:`hysteria2-uninstall` 然后重新 `hysteria2-install`。 + +## AWG:接口已启动但没有 `latest handshake` + +AmneziaWG 对参数不匹配保持沉默:peer 只是永远不出现握手。请检查「必须一致」组——它与每个 +客户端 `.conf` 必须完全相同: + +- `S1`–`S4`(3.x 引擎要求 ≥ 12)、`H1`–`H4`; +- 对端存在的任何 3.x 键:`HeaderProtectionKey`(3.0)与 `RandomTrailers`(3.1)必须逐字节 + 一致。Xrayebator 仅在 3.1 模式(`awg-31 --on`,新安装默认开启)下输出它们;不匹配通常意味着 + 客户端 `.conf` 早于 3.1 切换——请重新下载——或混入了第三方/手工配置; +- 客户端应用版本:带 `RandomTrailers` 的配置需要 AmneziaVPN ≥ 5.0.1.5;更旧的客户端可能直接 + 拒绝导入。 + +然后区分调试区域:服务器上的 `awg show awg0` ——若 peer 在列、握手存在但没有流量,问题在 +`awg-quick`/路由/防火墙区域(`ip_forward`、MASQUERADE 接口),而不是协议参数。 + +在怀疑参数之前,先验证网络路径本身。AWG 套接字位于内核空间,UDP 端口在 `ss`/`netstat` 中不可见 +——只有在客户端尝试连接时于服务器上抓包才能看到: + +```bash +tcpdump -l -ni 'udp port ' +``` + +抓不到任何包 → 数据包根本没有到达:运营商/DPI 过滤(典型的 RU 模式——UDP 443 上的 Hysteria 2 +可以通过,而高位随机 UDP 端口被静默丢弃)。抓到包但 `awg show` 仍无握手 → 客户端发送的是 +「垃圾」:重新签发 `.conf`,检查应用版本(3.1 参数需要 AmneziaVPN ≥ 5.0.1.5),或临时 +`awg-31 --off` 并重新导入以测试 2.0 兼容性。注意:`tcpdump` 重定向到文件时若不加 `-l` 会按块 +缓冲输出——早期数据包在缓冲区刷新前可能不可见。 + +## AmneziaVPN(完整应用)报错 1000,独立版客户端正常 + +`错误 1000` 是应用笼统的 `AndroidError`;手机端日志此时显示 +`VPN config format error: No value for client_ip`。应用的原生管道只接受自己的导出形态—— +`amnezia-awg2` 容器、与 `last_config` 并列的服务器级 junk 字段、`protocol_version` 以及压缩 +(`qCompress`)payload。裸 `.conf`(或缺少这些字段的朴素 `vpn://`)会被保存,但客户端部分 +到不了隧道。桌面 GUI 的「QR · AmneziaVPN」二维码构造的正是这种形态——请用它代替粘贴 +`.conf`。Xrayebator 安装的 junk 组与 Amnezia 默认值一致(`Jc` 5、`Jmin` 10、`Jmax` 50、 +`H1`–`H4` = 1..4),正是因为应用的 go-туннель对自定义 junk 应用不完整。 + +## AWG 安装失败 + +- 首选路径是 `amnezia/ppa` PPA;在没有对应发行版构建的系列上,Xrayebator 回退到源码编译。内核 + ≥ 5.6 时需要**完整的** `linux-source` 包——仅有内核头文件不够(模块构建会链接整个源码树)。 +- 安装后验证:`lsmod | grep amneziawg` 显示模块,`awg --version` 有响应。 +- 单元 `awg-quick@awg0` 读取 `/etc/amnezia/amneziawg/awg0.conf` —— Xrayebator 将其维护为指向 + `/usr/local/etc/xrayebator/backends/awg/awg0.conf` 的 symlink。删除 symlink 会让单元失效, + 尽管后端配置本身有效。 +- 卸载时软件包与内核模块有意保留在系统中;被移除的是接口、配置、symlink 与防火墙规则。 + +## 订阅中没有 `hysteria2://` 行 + +只有同时满足以下条件,`hysteria2://` 链接才会追加到两个订阅主体: + +1. Hysteria 2 后端已安装(`xrayebator hysteria2-status`); +2. 配置档持有授权(`profiles` JSON → `.backends.hysteria2.password`); +3. 注册表标志 `sub_body` 为 `true`(总开关——直接改 `backends.json`,处理器每次请求都会重读 + 注册表,无需重启服务)。 + +过期或停用的配置档不会得到该行;被吊销的配置档在下次刷新订阅时拿到新凭据。吊销后旧 token +URL 返回 `404` 属于预期——令牌本身已轮换。 + ## quickstart 报错 `apt-get install nginx failed` 新装的 Ubuntu VPS 上,`unattended-upgrades` 可能持有 apt/dpkg 锁约 10 分钟,并且对每个包单独调用 `dpkg`,因此简单的锁检查会从包与包之间的空隙漏过。quickstart 现在会额外等待活跃的 `unattended-upgrade` 进程(12 分钟预算),并给 `apt-get install` 传入 `-o DPkg::Lock::Timeout=180`。当 quickstart 提示 apt 超过预算仍被占用时,请稍后重试部署,或等更新队列结束。该行为由 `validation/test-apt-lock-race.sh` 锁定。 +## quickstart 报「certbot failed: ... Connection reset by peer」但仍成功结束 + +Let's Encrypt 无法获取 http-01 challenge——最常见原因是服务商网络对境外来源过滤 80 端口 +(已用 tcpdump 与多节点探测验证:本机防火墙与 nginx 正常,reset 发生在上游)。在此降级 +模式下部署以 `http_tls` 回退结束:结果 JSON 携带 `degraded:true` 与 `tls_mode:"http_tls"`, +GUI 将服务器标记为「配置不完整」,密钥通过 SSH 加载。此模式下不存在公共订阅 +URL——不会出现可泄露的 HTTP 链接。恢复 HTTPS 的方式:请服务商为 Let's Encrypt validation 网段解除 80 端口 +封锁(或将域名解析到服务器并使用域名 TLS 模式),然后重新部署;流程幂等,challenge +可达后即签发 LE 证书。回退结构由 `validation/test-quickstart-tls-fallback.sh` 锁定。 + ## 安装或使用过程中出现报错 请完整复制终端中的报错文本。如果问题出在 Xrayebator 代码上,请提交 issue。 \ No newline at end of file diff --git a/install.sh b/install.sh index 230f0198..46b6f767 100644 --- a/install.sh +++ b/install.sh @@ -1118,16 +1118,19 @@ if [[ $? -eq 0 ]] && [[ -s "${DATA_DIR}/sni_list.txt" ]]; then else echo -e "${YELLOW}⚠ Не удалось загрузить список SNI, создаю базовый...${NC}" cat > "${DATA_DIR}/sni_list.txt" << 'EOF' -www.ozon.ru|ru_whitelist|1 -wildberries.ru|ru_whitelist|1 -sberbank.ru|ru_whitelist|1 -nspk.ru|ru_whitelist|1 +# Аварийный список: не удалось скачать sni_list.txt из репозитория. +# Первая строка = SNI по умолчанию: www.cloudflare.com (выбор 2026-10-06 — +# самый стабильный; порядок остального по замеру 03.09.2026, MegaFon). +www.cloudflare.com|foreign|1 +api-maps.yandex.ru|yandex_cdn|1 +yandex.ru|yandex_cdn|1 +www.samsung.com|foreign|1 +dl.google.com|foreign|1 +swcdn.apple.com|foreign|1 +gateway.icloud.com|foreign|1 +www.lovelive-anime.jp|foreign|1 speller.yandex.net|yandex_cdn|2 -gosuslugi.ru|ru_whitelist|1 -stats.vk-portal.net|ru_whitelist|1 -github.com|foreign|3 -cloudflare.com|foreign|3 -www.microsoft.com|foreign|3 +www.gosuslugi.ru|ru_whitelist|2 EOF fi diff --git a/resources/icons/amnezia-logo.jpg b/resources/icons/amnezia-logo.jpg new file mode 100644 index 00000000..aee9b69e Binary files /dev/null and b/resources/icons/amnezia-logo.jpg differ diff --git a/resources/icons/hysteria_logo.svg b/resources/icons/hysteria_logo.svg new file mode 100644 index 00000000..17278d18 --- /dev/null +++ b/resources/icons/hysteria_logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sni_list.txt b/sni_list.txt index afde0df8..a66eb572 100644 --- a/sni_list.txt +++ b/sni_list.txt @@ -1,82 +1,105 @@ # XRAYEBATOR - Категоризированный список SNI -# Последнее обновление: май 2026 +# Последнее обновление: 03.09.2026 (замер MegaFon) — приоритеты не догма # Формат: домен|категория|приоритет # Категории: ru_whitelist, yandex_cdn, foreign, fallback - +# # ═══════════════════════════════════════════════════════════ -# БЕЛЫЙ СПИСОК РФ - Критичные сервисы (РКН их не блокирует) -# Использовать В ПЕРВУЮ ОЧЕРЕДЬ — максимальный шанс обхода +# ⚠ ПРО ЗАМЕР И ПОЧЕМУ ПРИОРИТЕТЫ НАДО ПЕРЕПРОВЕРЯТЬ # ═══════════════════════════════════════════════════════════ +# Замер: 03.09.2026, MegaFon (Москва), TLS-рукопожатие к 85.215.189.108. +# ТСПУ фильтрует по SNI, и набор режущихся имён РАЗЛИЧАЕТСЯ ПО ОПЕРАТОРУ, +# РЕГИОНУ И ДНЮ. Приоритеты ниже — ранг по этому замеру, а не гарантия: +# перепроверьте на своём операторе (`xrayebator` → probe test, либо +# openssl s_client -connect <ваш_ip>:443 -servername <домен> -tls1_3). +# +# Результат замера: часть «вечно белых» доменов (ozon, wildberries, +# tinkoff) не прошла рукопожатие в тот день — они понижены до 2 с +# оговоркой, но НЕ выброшены: на других операторах работают (подтверждено +# живыми профилями). Прошли и подняты в приоритет 1: cloudflare, +# api-maps.yandex.ru, samsung, dl.google.com, swcdn.apple.com, +# icloud-gateway, lovelive-anime. +# +# НЕ годятся как Reality dest (SNI здесь используется и как dest <домен>:443): +# mail.ru — отдаёт только TLS 1.2, Reality требует TLS 1.3 +# cdn-telegram.org — не резолвится +# +# Первая незакомментированная строка файла = SNI по умолчанию для новых +# профилей (_default_sni_for_transport) — стоит www.cloudflare.com (выбор +# 2026-10-06: самый стабильный, глобальный anycast); первые 20 строк = меню +# выбора SNI. -# E-commerce (никогда не блокируется) -www.ozon.ru|ru_whitelist|1 -api.ozon.ru|ru_whitelist|1 -st.ozon.ru|ru_whitelist|1 -ir.ozon.ru|ru_whitelist|1 - -www.wildberries.ru|ru_whitelist|1 -splitter.wb.ru|ru_whitelist|1 - -# Финансы (опорная инфраструктура) -www.sberbank.ru|ru_whitelist|1 -www.tinkoff.ru|ru_whitelist|1 -www.alfabank.ru|ru_whitelist|1 -www.nspk.ru|ru_whitelist|1 -sbp.nspk.ru|ru_whitelist|1 -online.uralsib.ru|ru_whitelist|1 -online.vtb.ru|ru_whitelist|1 - -# Логистика / госструктуры / маркетплейсы (приоритет 1) -www.cdek.ru|ru_whitelist|1 -www.pochta.ru|ru_whitelist|1 -www.avito.ru|ru_whitelist|1 - -# VK/Госмониторинг -stats.vk-portal.net|ru_whitelist|1 -sun6-21.userapi.com|ru_whitelist|1 -sun6-20.userapi.com|ru_whitelist|1 -queuev4.vk.com|ru_whitelist|1 -eh.vk.com|ru_whitelist|1 - -# Медиа/Развлечения -www.kinopoisk.ru|ru_whitelist|1 -www.1tv.ru|ru_whitelist|1 -www.rbc.ru|ru_whitelist|1 -www.vedomosti.ru|ru_whitelist|1 +# ═══════════════════════════════════════════════════════════ +# ПРИОРИТЕТ 1 — прошли замер 03.09.2026 (MegaFon, Москва) +# ═══════════════════════════════════════════════════════════ -# Госуслуги/Порталы -www.gosuslugi.ru|ru_whitelist|1 -www.mos.ru|ru_whitelist|1 -www.spb.gov.ru|ru_whitelist|1 +www.cloudflare.com|foreign|1 +api-maps.yandex.ru|yandex_cdn|1 +yandex.ru|yandex_cdn|1 +www.samsung.com|foreign|1 +dl.google.com|foreign|1 +swcdn.apple.com|foreign|1 +gateway.icloud.com|foreign|1 +www.lovelive-anime.jp|foreign|1 # ═══════════════════════════════════════════════════════════ -# YANDEX CDN - Рандомные поддомены (очень рекомендуется) -# Использовать ВТОРЫМ, если Tier-1 не работает +# ПРИОРИТЕТ 2 — рабочий набор: РФ-сервисы, CDN, медиа. +# ozon/wb/tinkoff — с оговоркой по замеру 03.09 (MegaFon): +# рукопожатие не прошло у них в тот день — чередуйте и проверяйте. # ═══════════════════════════════════════════════════════════ speller.yandex.net|yandex_cdn|2 -api-maps.yandex.ru|yandex_cdn|2 spell.yandex.net|yandex_cdn|2 advapi.yandex.net|yandex_cdn|2 mc.yandex.ru|yandex_cdn|2 suggest.yandex.ru|yandex_cdn|2 +stats.vk-portal.net|ru_whitelist|2 +queuev4.vk.com|ru_whitelist|2 +eh.vk.com|ru_whitelist|2 +sun6-21.userapi.com|ru_whitelist|2 +sun6-20.userapi.com|ru_whitelist|2 + +www.kinopoisk.ru|ru_whitelist|2 +www.1tv.ru|ru_whitelist|2 +www.rbc.ru|ru_whitelist|2 +www.vedomosti.ru|ru_whitelist|2 + +www.gosuslugi.ru|ru_whitelist|2 +www.mos.ru|ru_whitelist|2 +www.spb.gov.ru|ru_whitelist|2 + +www.cdek.ru|ru_whitelist|2 +www.pochta.ru|ru_whitelist|2 + +www.ozon.ru|ru_whitelist|2 +api.ozon.ru|ru_whitelist|2 +st.ozon.ru|ru_whitelist|2 +ir.ozon.ru|ru_whitelist|2 +www.wildberries.ru|ru_whitelist|2 +splitter.wb.ru|ru_whitelist|2 +www.tinkoff.ru|ru_whitelist|2 +www.sberbank.ru|ru_whitelist|2 +www.alfabank.ru|ru_whitelist|2 +www.nspk.ru|ru_whitelist|2 +sbp.nspk.ru|ru_whitelist|2 +online.uralsib.ru|ru_whitelist|2 +online.vtb.ru|ru_whitelist|2 +www.avito.ru|ru_whitelist|2 + # ═══════════════════════════════════════════════════════════ -# ИНОСТРАННЫЕ ДОМЕНЫ (не всегда проходят, в России могут фильтроваться) -# Использовать ТРЕТЬИМ — резервное решение +# ПРИОРИТЕТ 3 — иностранные, резерв # ═══════════════════════════════════════════════════════════ +www.microsoft.com|foreign|3 www.github.com|foreign|3 github.com|foreign|3 raw.githubusercontent.com|foreign|3 api.github.com|foreign|3 -www.cloudflare.com|foreign|3 cdn.cloudflare.net|foreign|3 aws.amazon.com|foreign|3 # ═══════════════════════════════════════════════════════════ -# FALLBACK (экстренный случай, часто уходит под подозрение DPI) -# Использовать ТОЛЬКО если ничего не сработало +# ПРИОРИТЕТ 4 — FALLBACK (крайний случай, быстро уходит под подозрение) # ═══════════════════════════════════════════════════════════ www.mozilla.org|fallback|4 @@ -85,11 +108,15 @@ www.debian.org|fallback|4 # ═══════════════════════════════════════════════════════════ # ПРИМЕЧАНИЯ: -# 1. Всегда чередуйте домены из первого уровня — не используйте один надолго -# 2. Yandex CDN-поддомены желательно обновлять раз в месяц -# 3. Категории: ru_whitelist — РФ, yandex_cdn — Яндекс CDN, foreign — зарубежные домены, fallback — крайний случай -# 4. Проверяйте SNI в вашем регионе — иногда Tier-1 могут временно не работать -# 5. Никогда НЕ используйте национальный DNS РФ (166.70.x.x) для критичного обхода -# 6. Используйте режим DNS — Auto (DoH → TCP) для наилучшей устойчивости (см. инструкции в документации) +# 1. SNI здесь работает и как Reality dest (<домен>:443) — домен обязан +# отвечать по TLS 1.3 с HTTP/2 и резолвиться с самого VPS. +# 2. Чередуйте домены приоритета 1 — не сидите на одном месяцами. +# 3. Категории: ru_whitelist — РФ, yandex_cdn — Яндекс, foreign — зарубежные, +# fallback — крайний случай. +# 4. Приоритет — ранг по замеру, фильтрация у MTS/Билайн/Ростелеком +# отличается от MegaFon. +# 5. Никогда НЕ используйте национальный DNS РФ для критичного обхода. +# 6. Режим DNS — Auto (DoH → TCP) даёт наилучшую устойчивость. # -# Обновляйте список! РКН реагирует на популярные SNI — актуальность максимум месяц +# Обновляйте список! ТСПУ реагирует на популярные SNI — актуальность +# замера максимум месяц. diff --git a/src/main/core/backend-manager.ts b/src/main/core/backend-manager.ts new file mode 100644 index 00000000..2e710a3c --- /dev/null +++ b/src/main/core/backend-manager.ts @@ -0,0 +1,252 @@ +import { SshClient, SshCredentials } from './ssh-client' +import { shellCommand } from './shell-command' +import { describeFailure, isUnknownCommandFailure, unknownCommandHint } from './cli-failure' +import { extractJson } from './profiles' +import type { + AwgConfResult, + BackendGrantResult, + BackendSimpleResult, + BackendStatusResult, + BackendToggleResult, + Hysteria2LinkResult, + Hysteria2SubbodyResult +} from '@shared/types' + +/** + * Разбор вывода `xrayebator backend-status`. Реестр может быть пустым объектом + * ({}) — тогда оба бэкенда считаются не установленными. + */ +export function parseBackendStatus(raw: string): BackendStatusResult { + const payload = extractJson(raw) as { + ok?: boolean + backends?: Record + error?: string + } + const backends: BackendStatusResult['backends'] = {} + for (const [type, value] of Object.entries(payload.backends ?? {})) { + if (value && typeof value === 'object') { + backends[type] = value as BackendStatusResult['backends'][string] + } + } + return { ok: payload.ok === true, backends, error: payload.error } +} + +/** + * Разбор вывода `xrayebator awg-conf --name N`. Поле conf — полный текст + * клиентского .conf (секреты уровня оператора: приватный ключ клиента, + * preshared key, серверные junk-параметры и HeaderProtectionKey). + */ +export function parseAwgConf(raw: string): AwgConfResult { + const payload = extractJson(raw) as { + ok?: boolean + name?: string + conf?: string + error?: string + } + return { + ok: payload.ok === true, + name: payload.name, + conf: typeof payload.conf === 'string' ? payload.conf : undefined, + error: payload.error + } +} + +/** + * Менеджер опциональных бэкендов (Hysteria 2, AmneziaWG 3.1). Тонкий SSH-фронт + * к JSON-командам xrayebator — GUI не знает про сервер больше, чем отдают эти + * команды. Модель этапа 2: бэкенды живут одновременно, доступ транспорта + * определяется грантами конкретного профиля. + */ +export class BackendManager { + constructor(private readonly creds: SshCredentials) {} + + private async run(args: readonly string[]): Promise { + const client = new SshClient(this.creds) + try { + await client.connect() + const command = shellCommand('xrayebator', args) + const res = await client.exec(command, { elevated: true }) + if (res.code !== 0) { + const reason = describeFailure(res) + const hint = isUnknownCommandFailure(res) ? ` ${unknownCommandHint()}` : '' + throw new Error( + `xrayebator ${args[0] ?? ''} → код ${res.code}${reason ? `: ${reason}` : ''}.${hint}` + ) + } + return res.stdout + } finally { + client.close() + } + } + + /** Сводный статус реестра бэкендов (установлены/активны/порты/флаги). */ + async status(): Promise { + try { + return parseBackendStatus(await this.run(['backend-status'])) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, backends: {}, error: message } + } + } + + /** + * Выдать/обновить грант Hysteria 2 профилю. Серверный конфиг регенерируется + * из всех профилей; выданным клиентам ничего перекачивать не нужно (их + * credы остаются в силе, меняется только server.yaml). + */ + async hysteria2Grant(name: string): Promise { + try { + const stdout = await this.run(['hysteria2-grant', '--name', name]) + const payload = extractJson(stdout) as { ok?: boolean; name?: string; error?: string } + return { ok: payload.ok === true, name: payload.name, error: payload.error } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + /** + * Установка Hysteria 2 (длинная: загрузка бинарника ~23 МБ). grantAll — + * выдать гранты всем существующим профилям (аналог --grant-all). + */ + async hysteria2Install(grantAll: boolean): Promise { + try { + const args = grantAll ? ['hysteria2-install', '--grant-all'] : ['hysteria2-install'] + const payload = extractJson(await this.run(args)) as { + ok?: boolean + already?: boolean + error?: string + } + return { ok: payload.ok === true, already: payload.already === true, error: payload.error } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + async hysteria2Uninstall(): Promise { + try { + const payload = extractJson(await this.run(['hysteria2-uninstall'])) as { + ok?: boolean + already?: boolean + error?: string + } + return { ok: payload.ok === true, already: payload.already === true, error: payload.error } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + /** Установка AmneziaWG (может включать сборку kernel-модуля — минуты). */ + async awgInstall(grantAll: boolean): Promise { + try { + const args = grantAll ? ['awg-install', '--grant-all'] : ['awg-install'] + const payload = extractJson(await this.run(args)) as { + ok?: boolean + already?: boolean + error?: string + } + return { ok: payload.ok === true, already: payload.already === true, error: payload.error } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + async awgUninstall(): Promise { + try { + const payload = extractJson(await this.run(['awg-uninstall'])) as { + ok?: boolean + already?: boolean + error?: string + } + return { ok: payload.ok === true, already: payload.already === true, error: payload.error } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + /** Kill-switch hysteria2-строк в подписке (handler перечитывает реестр сам). */ + async hysteria2Subbody(on: boolean): Promise { + try { + const stdout = await this.run(['hysteria2-subbody', on ? '--on' : '--off']) + const payload = extractJson(stdout) as { ok?: boolean; sub_body?: boolean; error?: string } + return { ok: payload.ok === true, sub_body: payload.sub_body, error: payload.error } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + /** hysteria2:// ссылка профиля (нужны установленный бэкенд и грант). */ + async hysteria2Link(name: string): Promise { + try { + const stdout = await this.run(['hysteria2-link', '--name', name]) + const payload = extractJson(stdout) as { + ok?: boolean + name?: string + link?: string + error?: string + } + return { + ok: payload.ok === true, + name: payload.name, + link: typeof payload.link === 'string' ? payload.link : undefined, + error: payload.error + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + /** Выдать/обновить AWG peer-грант профилю (keypair + PSK + адрес). */ + async awgGrant(name: string): Promise { + try { + const stdout = await this.run(['awg-grant', '--name', name]) + const payload = extractJson(stdout) as { ok?: boolean; name?: string; error?: string } + return { ok: payload.ok === true, name: payload.name, error: payload.error } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + /** Клиентский .conf AWG peer-а профиля (импорт в AmneziaVPN, не V2Ray/HAPP). */ + async awgConf(name: string): Promise { + try { + return parseAwgConf(await this.run(['awg-conf', '--name', name])) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } + + /** + * Тумблер AWG 3.1 (HeaderProtectionKey + RandomTrailers). Внимание UI: + * переключение меняет [Interface]-параметры — все выданные клиентские .conf + * должны быть перекачаны; клиентам нужен AmneziaVPN >= 5.0.1.5. + */ + async awg31(on: boolean): Promise { + try { + const stdout = await this.run(['awg-31', on ? '--on' : '--off']) + const payload = extractJson(stdout) as { + ok?: boolean + three_enabled?: boolean + disable_cookies?: boolean + error?: string + } + return { + ok: payload.ok === true, + three_enabled: payload.three_enabled, + disable_cookies: payload.disable_cookies, + error: payload.error + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } + } +} diff --git a/src/main/core/deployer.ts b/src/main/core/deployer.ts index c69aa63c..f90c6c01 100644 --- a/src/main/core/deployer.ts +++ b/src/main/core/deployer.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { app } from 'electron' import { SshClient, SshCredentials } from './ssh-client' import { shellCommand, shellQuote } from './shell-command' -import { fetchSubscription } from './subscription' +import { fetchSubscription, parseSubscription } from './subscription' import { maskSubscriptionUrl } from './server-inspector' import type { DeployStep, EmailMode, VlessLink } from '@shared/types' @@ -23,6 +23,8 @@ export interface DeployResult { country: string | null city: string | null flag: string | null + /** true — подписка в http_tls-fallback (LE не смог проверить http-01). */ + degraded: boolean } /** Путь к сценариям: в собранном приложении — resources/scripts, в dev — корень проекта. */ @@ -181,11 +183,28 @@ export class Deployer { } this.onStep('save', 'Сохраняю результат...') - const subUrl = payload.subscription_url - const keys = subUrl ? await fetchSubscription(subUrl) : [] + // http_tls-fallback: публичного HTTPS нет (LE не смог проверить http-01 — + // обычно порт 80 фильтруется хостером), но на сервере всё работает. GUI + // вытягивает ключи по SSH (curl на 127.0.0.1:8080 — loopback в firewall + // не нуждается), сервер сохраняется с setupStatus=partial. Мёртвый + // публичный URL не сохраняем и не пробуем fetch'ить. + const degraded = payload.degraded === true || payload.tls_mode === 'http_tls' + const subUrl = degraded ? '' : payload.subscription_url ?? '' + const fetched = degraded + ? await fetchSubscriptionOverSsh(client, payload.subscription_url ?? '') + : subUrl + ? await fetchSubscription(subUrl) + : { keys: [], hysteria2Links: [] } + const keys = fetched.keys if (!keys.length && subUrl) { throw new Error('Subscription вернул пустой список ключей') } + if (degraded) { + this.onLog( + `Подписка в http_tls-fallback (только SSH, публичного URL нет); ` + + `причина: ${payload.certbot_reason ?? 'LE validation недоступна'}` + ) + } this.onLog( `Подписка: ${subUrl ? maskSubscriptionUrl(subUrl) : '—'}; маршрутов получено: ${keys.length}` ) @@ -196,7 +215,8 @@ export class Deployer { os: payload.os ?? null, country: payload.country ?? null, city: payload.city ?? null, - flag: payload.flag ?? null + flag: payload.flag ?? null, + degraded } } finally { client.close() @@ -212,6 +232,9 @@ interface QuickstartJson { city?: string flag?: string os?: string + degraded?: boolean + tls_mode?: string + certbot_reason?: string } function parseQuickstartJson(raw: string): QuickstartJson { @@ -223,3 +246,23 @@ function parseQuickstartJson(raw: string): QuickstartJson { throw new Error('Не удалось разобрать ответ quickstart') } } + +/** + * Ключи по SSH в http_tls-fallback: публичный URL подписки недоступен из сети + * пользователя (хостер фильтрует HTTP/порты), но с server'а loopback работает + * всегда — curl 127.0.0.1:8080 и парсинг тела локально. + */ +async function fetchSubscriptionOverSsh( + client: SshClient, + url: string +): Promise<{ keys: VlessLink[]; hysteria2Links: string[] }> { + const res = await client.exec( + shellCommand('curl', ['-sS', '--connect-timeout', '5', '--max-time', '30', url]) + ) + if (res.code !== 0 || !res.stdout.trim()) { + throw new Error( + `Subscription недоступна локально на сервере (curl код ${res.code}): ${res.stderr.trim() || 'пусто'}` + ) + } + return { keys: parseSubscription(res.stdout), hysteria2Links: [] } +} diff --git a/src/main/core/server-inspector.ts b/src/main/core/server-inspector.ts index b79542ba..acdc1ab5 100644 --- a/src/main/core/server-inspector.ts +++ b/src/main/core/server-inspector.ts @@ -44,7 +44,11 @@ export function maskSubscriptionUrl(url: string): string { export function subscriptionProbeTarget(snapshot: InspectionSnapshot): string | null { const url = snapshot.subscription_url?.trim() ?? '' if (!url) return null + // http_tls-fallback (quickstart при hoster-блокировке http-01): URL публичный, + // но HTTP — рабочий endpoint с server'а; probe из интернета пользователя может + // не пройти, поэтому сохраняем URL как metadata, но не считаем его «public». if (snapshot.subscription_mode === 'local_only') return null + if (snapshot.subscription_mode === 'http_tls') return null if (isLocalSubscriptionUrl(url)) return null if (!url.startsWith('https://')) return null return url @@ -55,6 +59,10 @@ function subscriptionState( probe: SubscriptionProbe ): SubscriptionState { if (!snapshot.subscription_installed) return 'missing' + // http_tls-fallback: endpoint работает с server'а (локальный curl подтверждён + // quickstart'ом), но из интернета пользователя может быть недоступен — + // честный отдельный статус, а не «public» и не «localOnly». + if (snapshot.subscription_mode === 'http_tls') return 'fallback' const publicUrl = subscriptionProbeTarget(snapshot) !== null if (!publicUrl) { return snapshot.subscription_mode === 'local_only' ? 'localOnly' : 'missing' @@ -98,8 +106,12 @@ export function normalizeInspection( // Публичный, но недосягаемый URL сохраняем как metadata — карточка помнит // endpoint; Keys-страница ориентируется на diagnostics.subscription. + // http_tls-fallback URL НЕ сохраняем: снаружи на него никто не слушает, + // мёртвый https-URL вводит в заблуждение (реальный канал — SSH). const subscriptionUrl = - subscription === 'localOnly' || subscription === 'missing' ? '' : publicUrl + subscription === 'localOnly' || subscription === 'missing' || subscription === 'fallback' + ? '' + : publicUrl return { setupStatus: ready ? 'ready' : 'partial', diff --git a/src/main/core/server-manager.ts b/src/main/core/server-manager.ts index 2ab8f267..aca5aade 100644 --- a/src/main/core/server-manager.ts +++ b/src/main/core/server-manager.ts @@ -21,6 +21,17 @@ export function isSafeUpdateBranch(branch: string): boolean { ) } +/** + * Ветка обновления: закреплённая на сервере (.current_branch) или явная. + * Явная ветка — бета-режим «обновиться с dev, не трогая main» (см. ServerManager.update). + * Префикс xrayebator- — тег v0.6.0: legacy-серверы с .current_branch=xrayebator-0.2.0. + */ +export function isAllowedUpdateBranch(branch: string): boolean { + if (branch === 'main' || branch === 'dev' || branch === 'experimental') return true + if (branch.startsWith('xrayebator-')) return true + return isSafeUpdateBranch(branch) +} + /** * Операции над установкой на сервере: обновление скрипта/Xray и полное удаление. * Требует root-доступа напрямую или через sudo. @@ -31,13 +42,15 @@ export class ServerManager { /** * Обновление: self-update скрипта xrayebator с ветки + обновление Xray-core. * update_command с аргументом ветки делает self-update и exec'ится в свежий скрипт. + * Явная ветка (например, dev) — управляемая бета: сервер забирает менеджера с dev, + * не дожидаясь вливания в main; .current_branch на сервере переписывается на dev. */ - async update(): Promise { + async update(branch?: string): Promise { const client = new SshClient(this.creds) // Ветка берётся из .current_branch на сервере (её закрепляет // `xrayebator update `); main — только дефолт для серверов, - // где ветка ещё не закреплена. - let branch = 'main' + // где ветка ещё не закреплена. Явная ветка перекрывает обе. + let tracked = 'main' try { await client.connect() const result = await client.exec( @@ -50,13 +63,19 @@ export class ServerManager { if (result.code !== 0) { return { ok: false, error: 'Не удалось прочитать закреплённую ветку обновления' } } - const tracked = result.stdout.trim() - if (tracked) branch = tracked - if (!isSafeUpdateBranch(branch)) { - return { ok: false, error: 'На сервере записано некорректное имя ветки обновления' } + tracked = result.stdout.trim() || 'main' + const target = branch ?? tracked + if (!isAllowedUpdateBranch(target)) { + return { + ok: false, + error: + branch !== undefined + ? `Ветка обновления «${branch}» не разрешена: используйте main, dev или experimental` + : 'На сервере записано некорректное имя ветки обновления' + } } - const command = shellCommand('xrayebator', ['update', branch]) + const command = shellCommand('xrayebator', ['update', target]) const updated = await client.exec( shellCommand('timeout', ['600', 'sh', '-c', command]), { elevated: true } diff --git a/src/main/core/servers.ts b/src/main/core/servers.ts index 89fc41c6..85a24921 100644 --- a/src/main/core/servers.ts +++ b/src/main/core/servers.ts @@ -22,6 +22,8 @@ export interface ServerConnectionMetadata { privateKeyPersisted?: boolean | null passwordCredentialId?: string | null passwordPersisted?: boolean | null + /** Персистит http_tls-fallback (подписка работает только с server'а). */ + degraded?: boolean | null } export interface ServerStore { @@ -34,6 +36,11 @@ export interface ServerStore { connection: ServerConnectionMetadata ) => StoredServer updateKeys: (id: string, keys: VlessLink[]) => StoredServer | undefined + /** Персистит бэкенд-ключи: hysteria2-ссылки и AWG-конфиги per-profile. */ + updateBackendKeys: ( + id: string, + payload: { hysteria2Keys?: string[]; awgConfs?: Record } + ) => StoredServer | undefined updateConnection: (id: string, input: ServerConnectionMetadata) => StoredServer | undefined countCredentialReferences: (credentialId: string, exceptId?: string) => number clearCredentialReference: (id: string) => StoredServer | undefined @@ -60,11 +67,16 @@ function normalizeServer(server: StoredServer, hostKeys: Record) privateKeyPersisted: server.privateKeyPersisted ?? null, passwordCredentialId: server.passwordCredentialId ?? null, passwordPersisted: server.passwordPersisted ?? null, - setupStatus: server.setupStatus ?? (server.subscriptionUrl ? 'ready' : 'unknown'), + setupStatus: + server.setupStatus ?? + (server.subscriptionUrl ? 'ready' : 'unknown'), + degraded: server.degraded ?? false, diagnostics: server.diagnostics ?? null, hostKeyFingerprint: hostKeys[hostKeyId(server.host, server.port)] ?? server.hostKeyFingerprint ?? null, - keys: server.keys ?? [] + keys: server.keys ?? [], + hysteria2Keys: server.hysteria2Keys ?? [], + awgConfs: server.awgConfs ?? {} } } @@ -136,6 +148,9 @@ export function createServerStore(): ServerStore { subscriptionUrl: input.subscriptionUrl || existing?.subscriptionUrl || '', keys: input.keys?.length ? input.keys : existing?.keys ?? [], routesCount: input.routesCount ?? existing?.routesCount ?? null, + setupStatus: input.setupStatus ?? existing?.setupStatus ?? undefined, + diagnostics: input.diagnostics ?? existing?.diagnostics ?? null, + degraded: input.degraded ?? existing?.degraded ?? false, id: existing?.id ?? randomUUID(), createdAt: existing?.createdAt ?? new Date().toISOString(), hostKeyFingerprint: existingNormalized?.hostKeyFingerprint ?? input.hostKeyFingerprint ?? null @@ -158,6 +173,24 @@ export function createServerStore(): ServerStore { return updated }, + updateBackendKeys( + id: string, + payload: { hysteria2Keys?: string[]; awgConfs?: Record } + ): StoredServer | undefined { + const servers = store.get('servers') + const idx = servers.findIndex((s) => s.id === id) + if (idx === -1) return undefined + const updated: StoredServer = { + ...servers[idx], + hysteria2Keys: payload.hysteria2Keys ?? servers[idx].hysteria2Keys ?? [], + awgConfs: payload.awgConfs ?? servers[idx].awgConfs ?? {} + } + const next = [...servers] + next[idx] = updated + store.set('servers', next) + return updated + }, + updateConnection(id: string, input: ServerConnectionMetadata): StoredServer | undefined { const servers = store.get('servers') const idx = servers.findIndex((s) => s.id === id) diff --git a/src/main/core/subscription.ts b/src/main/core/subscription.ts index 1677dfc6..aef36a7a 100644 --- a/src/main/core/subscription.ts +++ b/src/main/core/subscription.ts @@ -74,10 +74,50 @@ export function parseSubscription(body: string): VlessLink[] { return links } +export function parseHysteria2Line(line: string): string | null { + const t = line.trim() + return t.startsWith('hysteria2://') ? t : null +} + +/** + * Извлекает hysteria2:// ссылки из тела подписки (HAPP и generic тела). + * Ключи AWG в подписке не живут — они доставляются .conf-файлом. + */ +export function extractHysteria2Links(body: string): string[] { + const links: string[] = [] + for (const line of body.split(/\r?\n/)) { + if (line.includes('://')) { + const l = parseHysteria2Line(line) + if (l) links.push(l) + continue + } + const candidate = line.trim() + if (candidate && !candidate.includes('://') && candidate.length > 40) { + try { + const decoded = decodeBase64(candidate) + if (decoded.includes('hysteria2://')) { + for (const inner of decoded.split(/\r?\n/)) { + const l = parseHysteria2Line(inner) + if (l) links.push(l) + } + } + } catch { + // не base64 — пропускаем + } + } + } + return links +} + +export interface SubscriptionFetch { + keys: VlessLink[] + hysteria2Links: string[] +} + export async function fetchSubscription( url: string, timeoutMs = 15000 -): Promise { +): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) @@ -90,7 +130,7 @@ export async function fetchSubscription( throw new Error(`HTTP ${res.status} ${res.statusText}`) } const body = await res.text() - return parseSubscription(body) + return { keys: parseSubscription(body), hysteria2Links: extractHysteria2Links(body) } } finally { clearTimeout(timer) } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 8d70adeb..ce0498e7 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -18,8 +18,11 @@ import type { } from '@shared/types' import type { ServerConnectionMetadata, ServerStore } from './core/servers' import { Deployer } from './core/deployer' -import { fetchSubscription } from './core/subscription' +import { fetchSubscription, parseSubscription } from './core/subscription' +import { shellCommand } from './core/shell-command' +import { SshClient } from './core/ssh-client' import { ProfileManager } from './core/profiles' +import { BackendManager } from './core/backend-manager' import { ServerManager } from './core/server-manager' import { ServerInspector } from './core/server-inspector' import { probePortsFor } from './core/probe-ports' @@ -268,6 +271,8 @@ export function registerIpcHandlers({ store }: IpcContext): void { privateKeyPersisted: access.privateKeyPersisted ?? null, passwordCredentialId: access.passwordCredentialId ?? null, passwordPersisted: access.passwordPersisted ?? null, + setupStatus: result.degraded ? 'partial' : 'ready', + degraded: result.degraded, hostKeyFingerprint: store.getHostKey(payload.host, payload.port) ?? null }) @@ -288,12 +293,58 @@ export function registerIpcHandlers({ store }: IpcContext): void { })() }) + // http_tls-fallback: ключи грузятся curl'ом на самом server'е (loopback). + // Переиспользуем resolveStoredSshPassword через credentialsFor — как SSH-страницы. + const fetchSubscriptionViaServer = async ( + serverId: string, + url: string + ): Promise<{ keys: ReturnType; hysteria2Links: string[] }> => { + const server = store.get(serverId) + if (!server) throw new Error('Сервер не найден') + const access: SshAccessInput = { + username: server.username, + authMethod: server.authMethod ?? 'password', + passwordCredentialId: server.passwordCredentialId ?? undefined, + passwordPersisted: server.passwordPersisted ?? false, + privilegeMode: server.privilegeMode ?? 'root' + } + const { credentials } = await credentialsFor(server, server, access) + const client = new SshClient(credentials) + try { + await client.connect() + const res = await client.exec( + shellCommand('curl', ['-sS', '--connect-timeout', '5', '--max-time', '30', url]) + ) + if (res.code !== 0 || !res.stdout.trim()) { + throw new Error( + `Subscription недоступна локально на сервере (curl код ${res.code}): ${res.stderr.trim() || 'пусто'}` + ) + } + const keys = parseSubscription(res.stdout) + if (!keys.length) throw new Error('Subscription вернул пустой список ключей') + return { keys, hysteria2Links: [] } + } finally { + client.close() + } + } + ipcMain.handle('subscription:fetch', async (_e, serverId: string) => { const server = store.get(serverId) if (!server) throw new Error('Сервер не найден') - const keys = await fetchSubscription(server.subscriptionUrl) + // http_tls-fallback: публичный URL недоступен с клиента — ключи тянутся + // curl'ом на самом server'е (loopback 127.0.0.1:8080). + const { keys, hysteria2Links } = server.degraded + ? await fetchSubscriptionViaServer(serverId, server.subscriptionUrl) + : await fetchSubscription(server.subscriptionUrl) store.updateKeys(serverId, keys) - return { serverId, subscriptionUrl: server.subscriptionUrl, keys } + // hysteria2-ключи персистятся рядом с vless — страница «Ключи» открывается мгновенно. + const updated = store.updateBackendKeys(serverId, { hysteria2Keys: hysteria2Links }) + return { + serverId, + subscriptionUrl: server.subscriptionUrl, + keys, + hysteria2Links: updated?.hysteria2Keys ?? hysteria2Links + } }) ipcMain.handle('servers:import', async (event, payload: ImportServerPayload) => { @@ -319,7 +370,8 @@ export function registerIpcHandlers({ store }: IpcContext): void { credentials, async (url) => { emitStep('subscription') - return fetchSubscription(url) + const fetched = await fetchSubscription(url) + return fetched.keys }, emitLog ) @@ -461,6 +513,119 @@ export function registerIpcHandlers({ store }: IpcContext): void { } ) + const backendManagerFor = async ( + serverId: string, + access: SshAccessInput + ): Promise => { + const server = store.get(serverId) + if (!server) throw new Error('Сервер не найден') + const { credentials } = await credentialsFor(server, server, access) + return new BackendManager(credentials) + } + + // Кэш AWG-конфигов персистится в карточке сервера (server.awgConfs) — + // страница «Ключей» показывает их мгновенно и обновляет в фоне. + ipcMain.handle('backends:status', async (_e, serverId: string, access: SshAccessInput) => { + const manager = await backendManagerFor(serverId, access) + const result = await manager.status() + if (!result.ok) throw new Error(result.error ?? 'Не удалось получить статус бэкендов') + return result + }) + + ipcMain.handle( + 'backends:hysteria2Grant', + async (_e, serverId: string, access: SshAccessInput, name: string) => { + if (!name) throw new Error('Не указано имя профиля') + const manager = await backendManagerFor(serverId, access) + return manager.hysteria2Grant(name) + } + ) + + ipcMain.handle( + 'backends:awgGrant', + async (_e, serverId: string, access: SshAccessInput, name: string) => { + if (!name) throw new Error('Не указано имя профиля') + const manager = await backendManagerFor(serverId, access) + const result = await manager.awgGrant(name) + if (result.ok) store.updateBackendKeys(serverId, { awgConfs: {} }) + return result + } + ) + + ipcMain.handle( + 'backends:awgConf', + async (_e, serverId: string, access: SshAccessInput, name: string) => { + if (!name) throw new Error('Не указано имя профиля') + const manager = await backendManagerFor(serverId, access) + const result = await manager.awgConf(name) + if (!result.ok) throw new Error(result.error ?? 'Не удалось получить конфиг AWG') + if (result.conf) { + const current = store.get(serverId) + store.updateBackendKeys(serverId, { + awgConfs: { ...(current?.awgConfs ?? {}), [name]: result.conf } + }) + } + return result + } + ) + + ipcMain.handle( + 'backends:awg31', + async (_e, serverId: string, access: SshAccessInput, on: boolean) => { + const manager = await backendManagerFor(serverId, access) + const result = await manager.awg31(Boolean(on)) + if (result.ok) store.updateBackendKeys(serverId, { awgConfs: {} }) + return result + } + ) + + ipcMain.handle( + 'backends:hysteria2Install', + async (_e, serverId: string, access: SshAccessInput, grantAll: boolean) => { + const manager = await backendManagerFor(serverId, access) + return manager.hysteria2Install(Boolean(grantAll)) + } + ) + + ipcMain.handle('backends:hysteria2Uninstall', async (_e, serverId: string, access: SshAccessInput) => { + const manager = await backendManagerFor(serverId, access) + return manager.hysteria2Uninstall() + }) + + ipcMain.handle( + 'backends:awgInstall', + async (_e, serverId: string, access: SshAccessInput, grantAll: boolean) => { + const manager = await backendManagerFor(serverId, access) + return manager.awgInstall(Boolean(grantAll)) + } + ) + + ipcMain.handle('backends:awgUninstall', async (_e, serverId: string, access: SshAccessInput) => { + const manager = await backendManagerFor(serverId, access) + const result = await manager.awgUninstall() + if (result.ok) store.updateBackendKeys(serverId, { awgConfs: {} }) + return result + }) + + ipcMain.handle( + 'backends:hysteria2Subbody', + async (_e, serverId: string, access: SshAccessInput, on: boolean) => { + const manager = await backendManagerFor(serverId, access) + return manager.hysteria2Subbody(Boolean(on)) + } + ) + + ipcMain.handle( + 'backends:hysteria2Link', + async (_e, serverId: string, access: SshAccessInput, name: string) => { + if (!name) throw new Error('Не указано имя профиля') + const manager = await backendManagerFor(serverId, access) + const result = await manager.hysteria2Link(name) + if (!result.ok) throw new Error(result.error ?? 'Не удалось получить hysteria2-ссылку') + return result + } + ) + const serverManagerFor = async ( serverId: string, access: SshAccessInput @@ -473,8 +638,13 @@ export function registerIpcHandlers({ store }: IpcContext): void { ipcMain.handle( 'server:update', - async (_e, serverId: string, access: SshAccessInput): Promise => { - return (await serverManagerFor(serverId, access)).update() + async ( + _e, + serverId: string, + access: SshAccessInput, + branch?: string + ): Promise => { + return (await serverManagerFor(serverId, access)).update(branch) } ) diff --git a/src/preload/index.ts b/src/preload/index.ts index 9951448d..21078194 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,13 @@ import { contextBridge, ipcRenderer } from 'electron' import type { + AwgConfResult, + BackendGrantResult, + BackendSimpleResult, + BackendStatusResult, + BackendToggleResult, DeployEvent, + Hysteria2LinkResult, + Hysteria2SubbodyResult, DeployStartPayload, ElectronAPI, ImportProgressEvent, @@ -120,12 +127,65 @@ const api: ElectronAPI = { ipcRenderer.invoke('profiles:setExpire', serverId, access, input) }, + backends: { + status: (serverId: string, access: SshAccessInput): Promise => + ipcRenderer.invoke('backends:status', serverId, access), + hysteria2Grant: ( + serverId: string, + access: SshAccessInput, + name: string + ): Promise => + ipcRenderer.invoke('backends:hysteria2Grant', serverId, access, name), + hysteria2Install: ( + serverId: string, + access: SshAccessInput, + grantAll: boolean + ): Promise => + ipcRenderer.invoke('backends:hysteria2Install', serverId, access, grantAll), + hysteria2Uninstall: ( + serverId: string, + access: SshAccessInput + ): Promise => + ipcRenderer.invoke('backends:hysteria2Uninstall', serverId, access), + hysteria2Subbody: ( + serverId: string, + access: SshAccessInput, + on: boolean + ): Promise => + ipcRenderer.invoke('backends:hysteria2Subbody', serverId, access, on), + hysteria2Link: ( + serverId: string, + access: SshAccessInput, + name: string + ): Promise => + ipcRenderer.invoke('backends:hysteria2Link', serverId, access, name), + awgGrant: ( + serverId: string, + access: SshAccessInput, + name: string + ): Promise => + ipcRenderer.invoke('backends:awgGrant', serverId, access, name), + awgInstall: ( + serverId: string, + access: SshAccessInput, + grantAll: boolean + ): Promise => + ipcRenderer.invoke('backends:awgInstall', serverId, access, grantAll), + awgUninstall: (serverId: string, access: SshAccessInput): Promise => + ipcRenderer.invoke('backends:awgUninstall', serverId, access), + awgConf: (serverId: string, access: SshAccessInput, name: string): Promise => + ipcRenderer.invoke('backends:awgConf', serverId, access, name), + awg31: (serverId: string, access: SshAccessInput, on: boolean): Promise => + ipcRenderer.invoke('backends:awg31', serverId, access, on) + }, + server: { update: ( serverId: string, - access: SshAccessInput + access: SshAccessInput, + branch?: string ): Promise => - ipcRenderer.invoke('server:update', serverId, access), + ipcRenderer.invoke('server:update', serverId, access, branch), uninstall: (serverId: string, access: SshAccessInput): Promise => ipcRenderer.invoke('server:uninstall', serverId, access) } diff --git a/src/renderer/src/assets/amnezia-logo.jpg b/src/renderer/src/assets/amnezia-logo.jpg new file mode 100644 index 00000000..aee9b69e Binary files /dev/null and b/src/renderer/src/assets/amnezia-logo.jpg differ diff --git a/src/renderer/src/assets/hysteria-logo.svg b/src/renderer/src/assets/hysteria-logo.svg new file mode 100644 index 00000000..17278d18 --- /dev/null +++ b/src/renderer/src/assets/hysteria-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 391abfbc..a10a38b7 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -9,3 +9,13 @@ declare module '*.json' { const value: unknown export default value } + +declare module '*.svg' { + const src: string + export default src +} + +declare module '*.jpg' { + const src: string + export default src +} diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index c354324e..ba7e2170 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -16,6 +16,7 @@ "partial": "Partially configured", "unknown": "Imported" }, + "degradedHint": "Public TLS is unavailable (Let's Encrypt could not verify the IP: port 80 is blocked by the hoster). The subscription works: keys are loaded over SSH from the server. Re-run the deploy after port 80 is unblocked to enable HTTPS.", "empty": "Add your first server", "routes_one": "{{count}} route", "routes_other": "{{count}} routes", @@ -128,7 +129,20 @@ "copied": "Copied!", "qr": "QR code", "subscription": "Subscription URL", - "none": "No keys yet" + "none": "No keys yet", + "backendsHystNote": "UDP transport: the line is already in the subscription. Clients: HAPP, Hiddify, v2rayN.", + "backendsAwgNote": "AmneziaWG 3.1 — NOT V2Ray/HAPP: import the config into the AmneziaWG/AmneziaVPN app. Full tunnel, AmneziaVPN ≥ 5.0.1.5. One key — one device: create a separate profile per device.", + "backendsNotReady": "The backend is not active yet or the config is unavailable — check the status in Server Settings.", + "backendsNoAccess": "AmneziaWG keys require saved server access (password/key in the keychain) — open Server Settings once.", + "backendsEndpoint": "Endpoint", + "backendsAddress": "Address", + "backendsQrVpn": "QR · AmneziaVPN", + "backendsQrAwg": "QR · AmneziaWG", + "backendsReissue": "Re-issue", + "backendsReissuing": "Re-issuing…", + "backendsReissueConfirm": "Confirm?", + "backendsExpiryDate": "Expiry: {{date}}", + "backendsExpiryNone": "Expiry: unlimited" }, "settings": { "title": "Server profiles", @@ -178,6 +192,12 @@ "uninstalling": "Removing…", "updated": "Server updated from its tracked branch!", "updateFailed": "Failed to update server", + "updateBranchAuto": "Auto — as pinned on the server", + "updateBranchAutoHint": "Branch from .current_branch, otherwise main", + "updateBranchMain": "main — latest stable release", + "updateBranchMainHint": "The proven release for everyday use", + "updateBranchDev": "dev — every feature first", + "updateBranchDevHint": "May be unstable. Switch back to main by updating from main", "uninstallTitle": "Remove Xrayebator from the server?", "uninstallBody": "Xray, configurations, profiles, certificates and nginx will be removed from server «{{name}}». This action cannot be undone.", "uninstalled": "Xrayebator removed from the server", @@ -185,6 +205,7 @@ "error": "Error", "errorPassword": "Complete the SSH access details", "errorName": "Enter profile name", + "errorNameExists": "A profile named “{{name}}” already exists — delete it or pick another name", "changeFingerprint": "Change fingerprint", "changingFingerprint": "Changing fingerprint…", "fpTitle": "Change fingerprint", @@ -276,6 +297,44 @@ "createExpire": "Expiry date", "createExpireSet": "Expiry: {{date}}", "createExpireHint": "The profile is switched off automatically on the selected date. Leave it empty for unlimited.", - "expirePicked": "Selected: {{date}}" + "expirePicked": "Selected: {{date}}", + "backendsTitle": "Multi-protocol backends", + "backendsHint": "Extra transports on the same server. They run alongside Xray; transport access is decided by per-profile grants.", + "backendsUnsupported": "This server runs an older manager version — update it (button above) to manage backends.", + "backendsHyst": "Hysteria 2", + "backendsHystSub": "fast UDP transport · QUIC", + "backendsHystNote": "The key is added to the HAPP/v2rayNG subscription automatically. Clients: HAPP, Hiddify, v2rayN. One link — all devices simultaneously.", + "backendsAwg": "AmneziaWG 3.1", + "backendsAwgSub": "system VPN · full tunnel", + "backendsAwgNote": "Client is the AmneziaWG/AmneziaVPN app, NOT V2Ray/HAPP. Import .conf or QR. AmneziaVPN ≥ 5.0.1.5.", + "backendsActive": "Active", + "backendsNotInstalled": "Not installed", + "backendsDevBadge": "additional protocols", + "backendsDevNote": "Hysteria 2 and AmneziaWG are in development: the interface and behavior may change from release to release.", + "backendsCreateHint": "Pick a protocol — regular VLESS or multi-protocol — and press “Create profile”. Name, count and expiry are the fields above.", + "backendsCreateHystDesc": "Fast UDP backend over QUIC: the key joins the subscription, one link — all devices", + "backendsCreateAwgDesc": "System VPN on the AmneziaWG 3.1 kernel: full device tunnel, one key — one device", + "backendsCreateNotInstalled": "Backend is not installed — install it in the “Multi-protocol backends” section below", + "backendsCreated": "Profiles created, backend key issued: {{count}}", + "backendsCreatedPartial": "Profiles created, but some keys were not issued — check the backends section", + "backendsInstall": "Install", + "backendsInstalling": "Installing…", + "backendsUninstall": "Uninstall", + "backendsUninstalling": "Removing…", + "backendsSubBodyOn": "Disable in subscription", + "backendsSubBodyOff": "Enable in subscription", + "backends31On": "3.1 on", + "backends31Off": "3.1 off", + "backends31Enable": "Enable 3.1", + "backends31Disable": "Disable 3.1", + "backendsConfirmTitle": "Confirm the action", + "backendsConfirmHyst": "Remove Hysteria 2? The service, config and certificates are torn down; hysteria2 lines disappear from subscriptions; issued grants stop working.", + "backendsConfirmAwg": "Remove AmneziaWG? Interface, config and firewall rule are removed (packages and module stay in the system). All peer connections stop.", + "backendsConfirm31On": "Enable AWG 3.1 mode (HeaderProtectionKey + RandomTrailers)? Every issued client .conf must be re-downloaded. Clients need AmneziaVPN ≥ 5.0.1.5.", + "backendsConfirm31Off": "Disable AWG 3.1 mode? Configs return to the 2.0 format; every client .conf must be re-downloaded.", + "backendsExpiryBtn": "Expiry", + "backendsExpiryChoose": "Choose the profile whose expiry you want to change:", + "backendsConfirmRevokeHystAll": "Re-issue all Hysteria 2 keys? Old passwords stop working.", + "backendsConfirmRevokeAwgAll": "Re-issue all AWG peer keys? Old .conf files stop working." } } diff --git a/src/renderer/src/i18n/ru.json b/src/renderer/src/i18n/ru.json index 9f8e4452..8783461b 100644 --- a/src/renderer/src/i18n/ru.json +++ b/src/renderer/src/i18n/ru.json @@ -16,6 +16,7 @@ "partial": "Настроен частично", "unknown": "Импортирован" }, + "degradedHint": "Публичный TLS недоступен (Let's Encrypt не смог проверить IP: порт 80 блокируется хостером). Подписка работает: ключи загружаются с сервера по SSH. Повторный деплой после разблокировки :80 включит HTTPS.", "empty": "Добавьте первый сервер", "routes_one": "{{count}} маршрут", "routes_few": "{{count}} маршрута", @@ -129,7 +130,20 @@ "copied": "Скопировано!", "qr": "QR-код", "subscription": "Subscription URL", - "none": "Ключи ещё не получены" + "none": "Ключи ещё не получены", + "backendsHystNote": "UDP-транспорт: строка уже в подписке. Клиенты: HAPP, Hiddify, v2rayN.", + "backendsAwgNote": "AmneziaWG 3.1 — НЕ V2Ray/HAPP: импортируйте конфиг в приложение AmneziaWG/AmneziaVPN. Full-tunnel, AmneziaVPN ≥ 5.0.1.5. Один ключ — одно устройство: под каждое устройство создавайте отдельный профиль.", + "backendsNotReady": "Бэкенд ещё не активен или конфиг недоступен — проверьте статус в настройках сервера.", + "backendsNoAccess": "Для ключей AmneziaWG нужен сохранённый доступ к серверу (пароль/ключ в keychain) — зайдите в Server Settings один раз.", + "backendsEndpoint": "Endpoint", + "backendsAddress": "Адрес", + "backendsQrVpn": "QR · AmneziaVPN", + "backendsQrAwg": "QR · AmneziaWG", + "backendsReissue": "Перевыпустить", + "backendsReissuing": "Перевыпуск…", + "backendsReissueConfirm": "Подтвердить?", + "backendsExpiryDate": "Срок: {{date}}", + "backendsExpiryNone": "Срок: бессрочно" }, "settings": { "title": "Профили сервера", @@ -179,6 +193,12 @@ "uninstalling": "Удаление…", "updated": "Сервер обновлён из закреплённой ветки!", "updateFailed": "Не удалось обновить сервер", + "updateBranchAuto": "Авто — как закреплено на сервере", + "updateBranchAutoHint": "Ветка из .current_branch, иначе main", + "updateBranchMain": "main — последняя стабильная версия", + "updateBranchMainHint": "Проверенный релиз для повседневной работы", + "updateBranchDev": "dev — все фишки сразу", + "updateBranchDevHint": "Может быть нестабильной. Вернуться на main — обновлением с main", "uninstallTitle": "Удалить Xrayebator с сервера?", "uninstallBody": "С сервера «{{name}}» будут удалены Xray, конфигурации, профили, сертификаты и nginx. Это действие нельзя отменить.", "uninstalled": "Xrayebator удалён с сервера", @@ -186,6 +206,7 @@ "error": "Ошибка", "errorPassword": "Заполните данные SSH-доступа", "errorName": "Введите имя профиля", + "errorNameExists": "Профиль с именем «{{name}}» уже существует — удалите его или выберите другое имя", "changeFingerprint": "Сменить fingerprint", "changingFingerprint": "Сменяю fingerprint…", "fpTitle": "Сменить fingerprint", @@ -277,6 +298,44 @@ "createExpire": "Срок действия", "createExpireSet": "Срок: {{date}}", "createExpireHint": "Профиль отключится автоматически в выбранную дату. Можно оставить бессрочным.", - "expirePicked": "Выбрано: {{date}}" + "expirePicked": "Выбрано: {{date}}", + "backendsTitle": "Мультипротокольные бэкенды", + "backendsHint": "Дополнительные транспорты на том же сервере. Работают одновременно с Xray; доступ транспорта определяется грантами профиля.", + "backendsUnsupported": "Сервер работает на старой версии менеджера — обновите его (кнопка выше), чтобы управлять бэкендами.", + "backendsHyst": "Hysteria 2", + "backendsHystSub": "быстрый UDP-транспорт · QUIC", + "backendsHystNote": "Ключ добавляется в подписку HAPP/v2rayNG автоматически. Клиенты: HAPP, Hiddify, v2rayN. Одна ссылка — все устройства одновременно.", + "backendsAwg": "AmneziaWG 3.1", + "backendsAwgSub": "системный VPN · full-tunnel", + "backendsAwgNote": "Клиент — приложение AmneziaWG/AmneziaVPN, НЕ V2Ray/HAPP. Импорт .conf или QR. AmneziaVPN ≥ 5.0.1.5.", + "backendsActive": "Активен", + "backendsNotInstalled": "Не установлен", + "backendsDevBadge": "дополнительные протоколы", + "backendsDevNote": "Hysteria 2 и AmneziaWG — находятся в разработке: интерфейс и поведение могут меняться от обновления к обновлению.", + "backendsCreateHint": "Выберите протокол — обычный VLESS или мультипротокольный — и нажмите «Создать профиль». Имя, количество и срок — поля выше.", + "backendsCreateHystDesc": "Быстрый UDP-бэкенд на QUIC: ключ добавляется в подписку, одна ссылка — все устройства", + "backendsCreateAwgDesc": "Системный VPN на ядре AmneziaWG 3.1: полный туннель устройства, один ключ — одно устройство", + "backendsCreateNotInstalled": "Бэкенд не установлен — установите его в разделе «Мультипротокольные бэкенды» ниже", + "backendsCreated": "Профили созданы, ключ бэкенда выдан: {{count}}", + "backendsCreatedPartial": "Профили созданы, но часть ключей не выдана — проверьте раздел бэкендов", + "backendsInstall": "Установить", + "backendsInstalling": "Установка…", + "backendsUninstall": "Удалить", + "backendsUninstalling": "Удаление…", + "backendsSubBodyOn": "Выключить в подписке", + "backendsSubBodyOff": "Включить в подписке", + "backends31On": "3.1 включён", + "backends31Off": "3.1 выключен", + "backends31Enable": "Включить 3.1", + "backends31Disable": "Выключить 3.1", + "backendsConfirmTitle": "Подтвердите действие", + "backendsConfirmHyst": "Удалить Hysteria 2? Сервис, конфиг и сертификаты будут сняты; hysteria2-строки исчезнут из подписок; выданные гранты прекратят действовать.", + "backendsConfirmAwg": "Удалить AmneziaWG? Интерфейс, конфиг и правило firewall будут сняты (пакеты и модуль останутся в системе). Все peer-подключения прекратятся.", + "backendsConfirm31On": "Включить режим AWG 3.1 (HeaderProtectionKey + RandomTrailers)? Все выданные клиентские .conf нужно будет скачать заново. Клиентам нужен AmneziaVPN ≥ 5.0.1.5.", + "backendsConfirm31Off": "Выключить режим AWG 3.1? Конфиги вернутся к формату 2.0; все клиентские .conf нужно будет скачать заново.", + "backendsExpiryBtn": "Срок", + "backendsExpiryChoose": "Выберите профиль, для которого меняется срок:", + "backendsConfirmRevokeHystAll": "Перевыпустить все ключи Hysteria 2? Старые пароли перестанут работать.", + "backendsConfirmRevokeAwgAll": "Перевыпустить ключи всех AWG-peer'ов? Старые .conf перестанут работать." } } diff --git a/src/renderer/src/i18n/zh.json b/src/renderer/src/i18n/zh.json index 72925a3e..b5a25288 100644 --- a/src/renderer/src/i18n/zh.json +++ b/src/renderer/src/i18n/zh.json @@ -16,6 +16,7 @@ "partial": "配置不完整", "unknown": "已导入" }, + "degradedHint": "公共 TLS 不可用(Let's Encrypt 无法验证 IP:端口 80 被服务商拦截)。订阅仍然可用:密钥通过 SSH 从服务器加载。待服务商解除 80 端口封锁后重新部署即可启用 HTTPS。", "empty": "添加第一台服务器", "routes_other": "{{count}} 条路线", "keys": "密钥", @@ -127,7 +128,20 @@ "copied": "已复制!", "qr": "二维码", "subscription": "订阅地址", - "none": "暂无密钥" + "none": "暂无密钥", + "backendsHystNote": "UDP 传输:该行已在订阅中。客户端:HAPP、Hiddify、v2rayN。", + "backendsAwgNote": "AmneziaWG 3.1 —— 非 V2Ray/HAPP:请将配置导入 AmneziaWG/AmneziaVPN 应用。全隧道,AmneziaVPN ≥ 5.0.1.5。一个密钥 = 一台设备:请为每台设备创建单独的配置档。", + "backendsNotReady": "后端尚未激活或配置不可用——请在服务器设置中检查状态。", + "backendsNoAccess": "AmneziaWG 密钥需要已保存的服务器访问凭据(keychain 中的密码/密钥)——请先打开 Server Settings。", + "backendsEndpoint": "Endpoint", + "backendsAddress": "地址", + "backendsQrVpn": "QR · AmneziaVPN", + "backendsQrAwg": "QR · AmneziaWG", + "backendsReissue": "重新签发", + "backendsReissuing": "重新签发中…", + "backendsReissueConfirm": "确认?", + "backendsExpiryDate": "有效期:{{date}}", + "backendsExpiryNone": "有效期:无限" }, "settings": { "title": "服务器配置文件", @@ -177,6 +191,12 @@ "uninstalling": "删除中…", "updated": "服务器已从其跟踪分支更新!", "updateFailed": "服务器更新失败", + "updateBranchAuto": "自动——按服务器上固定的分支", + "updateBranchAutoHint": "读取 .current_branch,否则为 main", + "updateBranchMain": "main——最新稳定版本", + "updateBranchMainHint": "经过验证的日常使用版本", + "updateBranchDev": "dev——第一时间体验全部新功能", + "updateBranchDevHint": "可能不稳定。更新到 main 即可切回", "uninstallTitle": "从服务器删除 Xrayebator?", "uninstallBody": "将从服务器「{{name}}」删除 Xray、配置、配置档案、证书和 nginx。此操作无法撤销。", "uninstalled": "已从服务器删除 Xrayebator", @@ -184,6 +204,7 @@ "error": "错误", "errorPassword": "请完整填写 SSH 访问信息", "errorName": "请输入配置文件名称", + "errorNameExists": "名为“{{name}}”的配置档已存在——请删除它或换一个名称", "changeFingerprint": "更改指纹", "changingFingerprint": "正在更改指纹…", "fpTitle": "更改指纹", @@ -275,6 +296,44 @@ "createExpire": "有效期", "createExpireSet": "有效期:{{date}}", "createExpireHint": "配置将在所选日期自动停用。留空表示永久有效。", - "expirePicked": "已选择:{{date}}" + "expirePicked": "已选择:{{date}}", + "backendsTitle": "多协议后端", + "backendsHint": "同一服务器上的额外传输。与 Xray 并行运行;传输访问由配置档授权决定。", + "backendsUnsupported": "该服务器运行旧版管理器——请先更新(上方按钮),然后即可管理后端。", + "backendsHyst": "Hysteria 2", + "backendsHystSub": "高速 UDP 传输 · QUIC", + "backendsHystNote": "密钥会自动加入 HAPP/v2rayNG 订阅。客户端:HAPP、Hiddify、v2rayN。一条链接——所有设备同时使用。", + "backendsAwg": "AmneziaWG 3.1", + "backendsAwgSub": "系统级 VPN · 全隧道", + "backendsAwgNote": "客户端为 AmneziaWG/AmneziaVPN 应用,而非 V2Ray/HAPP。导入 .conf 或二维码。AmneziaVPN ≥ 5.0.1.5。", + "backendsActive": "运行中", + "backendsNotInstalled": "未安装", + "backendsDevBadge": "补充协议", + "backendsDevNote": "Hysteria 2 与 AmneziaWG 正在开发中:界面和行为可能随版本更新而变化。", + "backendsCreateHint": "选择协议——普通 VLESS 或多协议——然后点击“创建配置档”。名称、数量和有效期在上方字段中。", + "backendsCreateHystDesc": "基于 QUIC 的高速 UDP 后端:密钥自动加入订阅,一条链接——所有设备", + "backendsCreateAwgDesc": "基于 AmneziaWG 3.1 内核的系统级 VPN:整机隧道,一个密钥——一台设备", + "backendsCreateNotInstalled": "后端未安装——请在下方“多协议后端”部分安装", + "backendsCreated": "配置档已创建,后端密钥已签发:{{count}}", + "backendsCreatedPartial": "配置档已创建,但部分密钥未签发——请检查后端部分", + "backendsInstall": "安装", + "backendsInstalling": "安装中…", + "backendsUninstall": "卸载", + "backendsUninstalling": "卸载中…", + "backendsSubBodyOn": "在订阅中关闭", + "backendsSubBodyOff": "在订阅中启用", + "backends31On": "3.1 已启用", + "backends31Off": "3.1 已停用", + "backends31Enable": "启用 3.1", + "backends31Disable": "关闭 3.1", + "backendsConfirmTitle": "确认操作", + "backendsConfirmHyst": "移除 Hysteria 2?服务、配置与证书将被拆除;订阅中的 hysteria2 行消失;已签发授权失效。", + "backendsConfirmAwg": "移除 AmneziaWG?接口、配置与防火墙规则将被拆除(软件包与模块保留在系统中)。所有 peer 连接中断。", + "backendsConfirm31On": "启用 AWG 3.1 模式(HeaderProtectionKey + RandomTrailers)?所有已发出的客户端 .conf 需重新下载。客户端需 AmneziaVPN ≥ 5.0.1.5。", + "backendsConfirm31Off": "停用 AWG 3.1 模式?配置恢复 2.0 格式;所有客户端 .conf 需重新下载。", + "backendsExpiryBtn": "有效期", + "backendsExpiryChoose": "选择要更改有效期的配置档:", + "backendsConfirmRevokeHystAll": "重新签发所有 Hysteria 2 密钥?旧密码将失效。", + "backendsConfirmRevokeAwgAll": "重新签发所有 AWG peer 密钥?旧 .conf 将失效。" } } diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 4ae46887..18895723 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -178,6 +178,11 @@ export function Dashboard({ ? styles.setupBadgePartial : styles.setupBadgeDefault }`} + title={ + server.degraded + ? t('dashboard.degradedHint') + : undefined + } > {t(`dashboard.setup.${server.setupStatus ?? 'unknown'}`)} diff --git a/src/renderer/src/pages/ServerKeys.module.css b/src/renderer/src/pages/ServerKeys.module.css index 64519e14..47f655f1 100644 --- a/src/renderer/src/pages/ServerKeys.module.css +++ b/src/renderer/src/pages/ServerKeys.module.css @@ -79,6 +79,54 @@ margin-top: 10px; } +.keyNote { + margin: 0 0 8px; + font-size: 12px; + color: var(--text-2); + line-height: 1.5; +} + +.keyConfPre { + margin: 0 0 4px; + padding: 10px 12px; + background: var(--bg-1, rgba(127, 127, 127, 0.08)); + border: 1px solid var(--border); + border-radius: 10px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-all; + max-height: 220px; + overflow-y: auto; + user-select: text; +} + +.keysFields { + display: grid; + gap: 6px; + margin-bottom: 10px; +} + +.keysField { + display: flex; + align-items: baseline; + gap: 10px; + font-size: 12px; +} + +.keysFieldLabel { + color: var(--text-2); + min-width: 76px; + flex: none; +} + +.keysFieldValue { + font-family: var(--font-mono); + user-select: text; + word-break: break-all; +} + .copyAllBtn { margin-top: 8px; font-weight: 600; diff --git a/src/renderer/src/pages/ServerKeys.tsx b/src/renderer/src/pages/ServerKeys.tsx index c8d03e96..7f55ceae 100644 --- a/src/renderer/src/pages/ServerKeys.tsx +++ b/src/renderer/src/pages/ServerKeys.tsx @@ -2,8 +2,15 @@ import { useEffect, useState } from 'react' import QRCode from 'qrcode' import { Button, Chip, Spinner } from '@heroui/react' import { useTranslation } from 'react-i18next' -import type { Server, VlessLink } from '@shared/types' +import type { + Server, + ServerProfile, + SshAccessInput, + VlessLink +} from '@shared/types' import { vlessPort } from '@shared/vless' +import { buildAwgVpnUrl, parseAwgClientConf, stripAwgComments } from '@shared/awg' +import { shouldAutoConnectServer } from './server-access' import styles from './ServerKeys.module.css' interface ServerKeysProps { @@ -14,32 +21,115 @@ interface ServerKeysProps { export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Element { const { t } = useTranslation() const [keys, setKeys] = useState(server.keys ?? []) + // Бэкенд-ключи инициализируются из карточки сервера — мгновенно, как vless. + const [hysteria2Links, setHysteria2Links] = useState(server.hysteria2Keys ?? []) + const [awgConfs, setAwgConfs] = useState>(server.awgConfs ?? {}) const [subscriptionUrl, setSubscriptionUrl] = useState(server.subscriptionUrl) const [toast, setToast] = useState(null) const [loading, setLoading] = useState(false) const [qrUrl, setQrUrl] = useState(null) const [qrData, setQrData] = useState(null) + // Мультипротокольные бэкенды: hysteria2 приходит из подписки, AWG — по SSH. + const [profiles, setProfiles] = useState([]) + const [beBusy, setBeBusy] = useState(false) + + // Доступ из сохранённой карточки сервера (секрет — в keychain). + const storedAccess: SshAccessInput = { + username: server.username, + authMethod: server.authMethod ?? 'password', + passwordCredentialId: server.passwordCredentialId ?? undefined, + passwordPersisted: server.passwordPersisted ?? false, + privateKeyCredentialId: server.privateKeyCredentialId ?? undefined, + privateKeyPersisted: server.privateKeyPersisted ?? false, + privilegeMode: server.privilegeMode ?? 'root' + } + useEffect(() => { let cancelled = false - if (!server.subscriptionUrl) return - setLoading(true) - window.api.subscription - .fetch(server.id) - .then((result) => { - if (cancelled) return - setKeys(result.keys) - setSubscriptionUrl(result.subscriptionUrl) + // 1) МГНОВЕННАЯ отрисовка из персистентного стора: пропса server может быть + // устаревшим снимком Dashboard'а (без hysteria2Keys/awgConfs) — поэтому + // перечитываем свежую запись напрямую. + window.api.servers + .get(server.id) + .then((fresh) => { + if (cancelled || !fresh) return + setKeys(fresh.keys ?? []) + setHysteria2Links(fresh.hysteria2Keys ?? []) + setAwgConfs(fresh.awgConfs ?? {}) + setSubscriptionUrl(fresh.subscriptionUrl) }) .catch(() => { - if (!cancelled) setKeys(server.keys ?? []) - }) - .finally(() => { - if (!cancelled) setLoading(false) + // стора нет — остаёмся на пропсе }) + + // 2) Фоновое обновление: подписка + бэкенды, ВСЁ параллельно одним Promise.all + // (равный тайминг с vless; без каскада и разнобоя). + const beReady = shouldAutoConnectServer(server) + setLoading(true) + if (beReady) setBeBusy(true) + const subscriptionP = server.subscriptionUrl + ? window.api.subscription + .fetch(server.id) + .then((result) => { + if (cancelled) return + setKeys(result.keys) + setHysteria2Links(result.hysteria2Links ?? []) + setSubscriptionUrl(result.subscriptionUrl) + }) + .catch(() => { + if (!cancelled) setKeys(server.keys ?? []) + }) + : Promise.resolve() + + const backendP = (async (): Promise => { + if (!beReady) return + try { + const [pl] = await Promise.all([ + window.api.profiles.list(server.id, storedAccess) + ]) + if (cancelled) return + const list = pl.profiles ?? [] + setProfiles(list) + // Конфы granted-профилей — параллельно; кэш в сторе делает повторные + // открытия мгновенными. backends.status здесь не нужен — он тормозит. + const targets = list.filter((p) => p.backends?.awg) + const confEntries = await Promise.all( + targets.map(async (p) => { + const conf = await window.api.backends + .awgConf(server.id, storedAccess, p.name) + .catch(() => null) + return [p.name, conf?.ok ? (conf.conf ?? '') : ''] as const + }) + ) + if (cancelled) return + setAwgConfs((current) => { + const merged: Record = { ...current } + for (const [name, conf] of confEntries) { + // Неудачный refetch НЕ стирает сохранённый конфиг (last known good). + if (conf) merged[name] = conf + } + return merged + }) + // Финальная синхронизация со стором: состояние == персистентные данные. + const fresh = await window.api.servers.get(server.id) + if (!cancelled && fresh?.awgConfs) setAwgConfs(fresh.awgConfs) + } catch { + if (!cancelled) setProfiles([]) + } + })() + + void Promise.all([subscriptionP, backendP]).finally(() => { + if (!cancelled) { + setLoading(false) + setBeBusy(false) + } + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [server.id]) useEffect(() => { @@ -69,6 +159,41 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme setQrData(dataUrl) } + /** QR для импорта в AmneziaWG-клиент: без комментариев-шапки. */ + const awgQrPayload = (conf: string): string => stripAwgComments(conf) + + const hystUserFromLink = (link: string): string => { + const user = link.match(/^hysteria2:\/\/([^:]+):/)?.[1] ?? '' + try { + return decodeURIComponent(user) + } catch { + return user + } + } + + const expiryLine = (profile: ServerProfile | undefined): React.JSX.Element | null => + profile ? ( +

+ {profile.expire_date + ? t('keys.backendsExpiryDate', { date: profile.expire_date }) + : t('keys.backendsExpiryNone')} +

+ ) : null + + const hysteria2Port = (link: string): string => { + const m = link.match(/@[^/:]+:(\d+)/) + return m ? m[1] : '' + } + + // Карточки AWG: из стора (мгновенно, до ответа SSH) + granted-профили, у + // которых конфига ещё нет (для них — спиннер внутри карточки). + const awgNames = [ + ...new Set([ + ...Object.keys(awgConfs), + ...profiles.filter((p) => p.backends?.awg).map((p) => p.name) + ]) + ] + return (
@@ -109,6 +234,102 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme
))} + {hysteria2Links.map((link) => { + const user = hystUserFromLink(link) + const profile = profiles.find((p) => p.name === user) + return ( +
+
+ + HYSTERIA2 · {user} · UDP :{hysteria2Port(link)} + +
+

{t('keys.backendsHystNote')}

+ {expiryLine(profile)} +
+ {link} +
+
+ + +
+
+ ) + })} + + {awgNames.map((name) => { + const conf = awgConfs[name] + const fields = conf ? parseAwgClientConf(conf) : null + const profile = profiles.find((p) => p.name === name) + const granted = Boolean(profile?.backends?.awg) || Boolean(conf) + return ( +
+
+ + AMNEZIAWG 3.1 · {name} + +
+

{t('keys.backendsAwgNote')}

+ {expiryLine(profile)} + {fields ? ( + <> +
+
+ + {t('keys.backendsEndpoint')} + + {fields.endpoint} +
+
+ + {t('keys.backendsAddress')} + + {fields.address} +
+
+
+ + + +
+ + ) : granted && beBusy ? ( + + ) : ( +
{t('keys.backendsNotReady')}
+ )} +
+ ) + })} + {subscriptionUrl && (
@@ -133,7 +354,13 @@ export function ServerKeys({ server, onBack }: ServerKeysProps): React.JSX.Eleme variant="primary" size="lg" onPress={() => - copy(keys.map((k) => k.url).join('\n') + '\n' + subscriptionUrl) + copy( + keys.map((k) => k.url).join('\n') + + '\n' + + hysteria2Links.join('\n') + + '\n' + + subscriptionUrl + ) } > {t('keys.copy_all')} diff --git a/src/renderer/src/pages/ServerSettings.module.css b/src/renderer/src/pages/ServerSettings.module.css index c59431c4..5dd37d5f 100644 --- a/src/renderer/src/pages/ServerSettings.module.css +++ b/src/renderer/src/pages/ServerSettings.module.css @@ -61,6 +61,61 @@ } } +/* Выбор ветки обновления: обычная (закреплённая/main) и бета (dev). */ +.updateMenuWrap { + position: relative; +} + +.updateMenu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + min-width: 260px; + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-2); + box-shadow: 0 10px 30px color-mix(in srgb, var(--text) 18%, transparent); +} + +.updateMenuItem { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.updateMenuItem:hover { + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.updateMenuItem:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.updateMenuBeta { + color: var(--warning); +} + +.updateMenuHint { + font-size: 11px; + opacity: 0.65; + color: var(--text); +} + .header { display: flex; align-items: center; @@ -112,6 +167,324 @@ padding: 18px 20px; } +/* --- Мультипротокольные бэкенды (этап 2) --- */ +/* Один общий бокс, внутри — блоки протоколов один под другим. */ +.backendBlock { + padding: 2px 0 10px; +} + +.backendBlock + .backendBlock { + margin-top: 2px; +} + +.backendHead { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.backendIcon { + width: 34px; + height: 34px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex: none; + overflow: hidden; +} + +.backendIconHyst { + background: rgba(250, 204, 21, 0.14); + color: #facc15; +} + +.backendIconAwg { + background: rgba(56, 189, 248, 0.14); + color: #38bdf8; +} + +/* Логотипы протоколов: SVG Hysteria — вписан, JPG Amnezia — заливает бейдж */ +.backendIconSvg { + width: 24px; + height: 20px; + object-fit: contain; +} + +.backendIconImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.backendTitle { + display: flex; + flex-direction: column; + min-width: 0; +} + +.backendName { + font-size: 14px; + font-weight: 600; + line-height: 1.2; +} + +.backendSub { + font-size: 12px; + color: var(--text-2); +} + +.backendState { + font-size: 12px; + color: var(--text-2); + margin-bottom: 6px; +} + +.backendStateOk { + color: var(--success, #2fb344); +} + +/* Единая внутренняя выемка на ОБА бэкенда — как затемнённые inner-box ключей. */ +.backendsInner { + background: var(--bg-1, rgba(127, 127, 127, 0.1)); + border-radius: 12px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.backendBlock { + display: flex; + flex-direction: column; +} + +.backendNote { + margin: 0; + font-size: 12px; + color: var(--text-2); + line-height: 1.5; +} + +.backendActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +/* --- Создание профилей бэкендов (карточки под транспортом) --- */ +.backendCreate { + margin-top: 14px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.backendCreateGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.backendCreateCard { + display: flex; + align-items: center; + gap: 12px; + text-align: left; + font-family: var(--font); + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--bg-1); + color: var(--text-1); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.backendCreateCard:hover:not(:disabled) { + color: var(--text-0); + border-color: var(--accent); +} + +.backendCreateCard:disabled { + opacity: 0.5; + cursor: default; +} + +.backendCreateText { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.backendCreateName { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; + color: var(--text-0); +} + +.backendCreateDesc { + font-size: 12px; + line-height: 1.45; + color: var(--text-2); +} + +.backendCreateCardActive { + background: color-mix(in srgb, var(--accent) 11%, var(--bg-1)); + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); +} + +.backendCreateCardActive .backendCreateName { + color: color-mix(in srgb, var(--accent) 70%, var(--text-0)); +} + +/* --- Бэкенд-боксы профилей в списке --- */ +/* Самостоятельные боксы уровня карточки профиля (сиблинги во Fragment), + а не вложенные sub-rows: «основной бокс + боксы протоколов рядом». */ +.backendProfileCard { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.backendProfileHead { + display: flex; + align-items: center; + gap: 10px; +} + +.backendProfileTitle { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.backendProfileSub { + font-size: 12px; + color: var(--text-2); +} + +@media (max-width: 720px) { + .backendCreateGrid { + grid-template-columns: 1fr; + } +} + +.backendSkeleton { + height: 76px; + border-radius: 12px; + margin-bottom: 10px; + background: linear-gradient( + 90deg, + var(--bg-2) 25%, + var(--bg-1, rgba(127, 127, 127, 0.14)) 50%, + var(--bg-2) 75% + ); + background-size: 200% 100%; + animation: backendSkeleton 1.4s ease infinite; +} + +@keyframes backendSkeleton { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +.keysFields { + display: grid; + gap: 6px; + margin-bottom: 10px; +} + +.keysField { + display: flex; + align-items: baseline; + gap: 10px; + font-size: 12px; +} + +.keysFieldLabel { + color: var(--text-2); + min-width: 76px; + flex: none; +} + +.keysFieldValue { + font-family: var(--font-mono); + user-select: text; + word-break: break-all; +} + +/* --- Диалог ключей бэкендов (срез C) --- */ +.keysBlock { + margin: 0 0 16px; +} + +.keysChipRow { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.keysNote { + margin: 0 0 8px; + font-size: 12px; + color: var(--text-2); + line-height: 1.5; +} + +.keysConfPre { + margin: 0; + padding: 10px 12px; + background: var(--bg-1, rgba(127, 127, 127, 0.08)); + border: 1px solid var(--border); + border-radius: 10px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-all; + max-height: 220px; + overflow-y: auto; + user-select: text; +} + +.keysQrModal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 300; +} + +.keysQrModal img { + padding: 16px; + background: #fff; + border-radius: var(--radius-md); +} + .hint { margin: 0 0 14px; font-size: 14px; diff --git a/src/renderer/src/pages/ServerSettings.tsx b/src/renderer/src/pages/ServerSettings.tsx index 0f2bcb00..69340a16 100644 --- a/src/renderer/src/pages/ServerSettings.tsx +++ b/src/renderer/src/pages/ServerSettings.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import QRCode from 'qrcode' import { Button, TextField, Label, Input, Chip, Spinner, AlertDialog } from '@heroui/react' import { Settings2, @@ -15,16 +16,26 @@ import { Check, ShieldOff, CalendarClock, - CalendarX + CalendarX, + Zap } from 'lucide-react' import { useTranslation } from 'react-i18next' -import type { Server, ServerProfile, SniEntry, SshAccessInput } from '@shared/types' +import type { + BackendStatusResult, + Server, + ServerProfile, + SniEntry, + SshAccessInput +} from '@shared/types' import { describeExpire, isFutureDate, presetDate } from '@shared/expire' import { todayIso } from '@shared/calendar' +import { buildAwgVpnUrl, stripAwgComments } from '@shared/awg' import { isSshAccessReady, SshAccessForm } from '../components/SshAccessForm' import { CalendarPicker } from '../components/CalendarPicker' import { shouldAutoConnectServer } from './server-access' import styles from './ServerSettings.module.css' +import hystLogo from '../assets/hysteria-logo.svg' +import amneziaLogo from '../assets/amnezia-logo.jpg' interface ServerSettingsProps { server: Server @@ -83,6 +94,23 @@ export function ServerSettings({ const [busy, setBusy] = useState(false) const autoConnectStarted = useRef(false) const [profiles, setProfiles] = useState(null) + const [backends, setBackends] = useState(null) + const [backendsLoading, setBackendsLoading] = useState(false) + const [backendsError, setBackendsError] = useState(null) + const [backendBusy, setBackendBusy] = useState(null) + const [confirmBackend, setConfirmBackend] = useState< + | 'hysteria2-uninstall' + | 'awg-uninstall' + | 'awg31-on' + | 'awg31-off' + | 'hysteria2-revoke-all' + | 'awg-revoke-all' + | null + >(null) + // QR-модалка выдачи ключей бэкенд-боксов. Диалог «Ключи» убран: + // полное копирование только на странице Ключей (решение 2026-10-06). + const [keysQrUrl, setKeysQrUrl] = useState(null) + const [keysQrData, setKeysQrData] = useState(null) const [error, setError] = useState(null) const [toast, setToast] = useState(null) const [hostKeyFingerprint, setHostKeyFingerprint] = useState( @@ -95,10 +123,14 @@ export function ServerSettings({ const [transport, setTransport] = useState('xhttp') const [count, setCount] = useState('1') const [creating, setCreating] = useState(false) + // Выбор мультипротокольного бэкенда в «Создать профиль»: клик по карточке + // = только выбор (как у VLESS-транспортов), создание — кнопкой «Создать профиль». + const [backendSel, setBackendSel] = useState<'hysteria2' | 'awg' | null>(null) const [createExpire, setCreateExpire] = useState('') const [createExpireOpen, setCreateExpireOpen] = useState(false) const [updating, setUpdating] = useState(false) + const [updateMenuOpen, setUpdateMenuOpen] = useState(false) const [uninstalling, setUninstalling] = useState(false) const [confirmUninstall, setConfirmUninstall] = useState(false) const [confirmRemove, setConfirmRemove] = useState(null) @@ -128,8 +160,10 @@ export function ServerSettings({ const [revokeStep, setRevokeStep] = useState<'choose' | 'confirm' | 'done'>('choose') const [revokeBusy, setRevokeBusy] = useState(false) const [revokeUrl, setRevokeUrl] = useState(null) + const [revokeFullOnly, setRevokeFullOnly] = useState(false) const [expireTarget, setExpireTarget] = useState(null) + const [expireChooser, setExpireChooser] = useState(null) const [expireDate, setExpireDate] = useState('') const [expireBusy, setExpireBusy] = useState(false) const [expireDone, setExpireDone] = useState(false) @@ -160,8 +194,22 @@ export function ServerSettings({ setBusy(true) setError(null) try { + // Бэкенды грузятся ПАРАЛЛЕЛЬНО с профилями (фидбек: не заставлять ждать). + setBackendsLoading(true) + const backendsP = window.api.backends + .status(server.id, access) + .then((b) => { + setBackends(b) + setBackendsError(null) + }) + .catch((err: unknown) => { + setBackends(null) + setBackendsError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => setBackendsLoading(false)) const result = await window.api.profiles.list(server.id, access) setProfiles(result.profiles ?? []) + await backendsP const refreshed = await window.api.servers.get(server.id) setHostKeyFingerprint(refreshed?.hostKeyFingerprint ?? null) if (refreshed) { @@ -187,6 +235,15 @@ export function ServerSettings({ if (shouldAutoConnectServer(server)) void load() }, [server.id]) + useEffect(() => { + if (!keysQrUrl) return + const onEsc = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setKeysQrUrl(null) + } + document.addEventListener('keydown', onEsc) + return () => document.removeEventListener('keydown', onEsc) + }, [keysQrUrl]) + const create = async (): Promise => { if (!accessReady) { setError(t('settings.errorPassword')) @@ -196,6 +253,14 @@ export function ServerSettings({ setError(t('settings.errorName')) return } + // Pre-check коллизии имени (см. createBackendProfiles). + const nameCollision = futureNames.find((nm) => + (profiles ?? []).some((p) => p.name === nm) + ) + if (nameCollision) { + setError(t('settings.errorNameExists', { name: nameCollision })) + return + } setBusy(true) setCreating(true) setError(null) @@ -376,9 +441,10 @@ export function ServerSettings({ toastText(t('settings.copied')) } - const openRevoke = (profile: ServerProfile): void => { + const openRevoke = (profile: ServerProfile, forceFull = false): void => { setRevokeTarget(profile) - setRevokeStep('choose') + setRevokeFullOnly(forceFull) + setRevokeStep(forceFull ? 'confirm' : 'choose') setRevokeUrl(null) } @@ -493,13 +559,13 @@ export function ServerSettings({ */ const expireSupported = (profiles ?? []).some((p) => p.expire_supported === true) - const updateServer = async (): Promise => { + const updateServer = async (branch?: 'main' | 'dev' | 'experimental'): Promise => { if (!accessReady) return setBusy(true) setUpdating(true) setError(null) try { - const result = await window.api.server.update(server.id, access) + const result = await window.api.server.update(server.id, access, branch) if (result.ok) { toastText(t('settings.updated')) } else { @@ -510,9 +576,114 @@ export function ServerSettings({ } finally { setBusy(false) setUpdating(false) + setUpdateMenuOpen(false) + } + } + + const reloadBackends = async (): Promise => { + try { + setBackends(await window.api.backends.status(server.id, access)) + setBackendsError(null) + } catch (err) { + setBackendsError(err instanceof Error ? err.message : String(err)) + } + try { + const fresh = await window.api.profiles.list(server.id, access) + if (fresh.ok) setProfiles(fresh.profiles) + } catch { + // профили уже загружены ранее — молча оставляем как есть + } + } + + const runBackendAction = async ( + id: string, + action: () => Promise + ): Promise => { + if (!accessReady) return + setBusy(true) + setBackendBusy(id) + setError(null) + try { + await action() + await reloadBackends() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + setBackendBusy(null) + } + } + + const installHysteria2 = (): Promise => + runBackendAction('hysteria2-install', () => + window.api.backends.hysteria2Install(server.id, access, true) + ) + const uninstallHysteria2 = (): Promise => { + setConfirmBackend(null) + return runBackendAction('hysteria2-uninstall', () => + window.api.backends.hysteria2Uninstall(server.id, access) + ) + } + const toggleSubbody = (): Promise => + runBackendAction('subbody', () => + window.api.backends.hysteria2Subbody( + server.id, + access, + !(backends?.backends.hysteria2?.sub_body ?? false) + ) + ) + const installAwg = (): Promise => + runBackendAction('awg-install', () => + window.api.backends.awgInstall(server.id, access, true) + ) + const uninstallAwg = (): Promise => { + setConfirmBackend(null) + return runBackendAction('awg-uninstall', () => + window.api.backends.awgUninstall(server.id, access) + ) + } + const toggle31 = (on: boolean): Promise => { + setConfirmBackend(null) + return runBackendAction('awg31', () => window.api.backends.awg31(server.id, access, on)) + } + + // Revoke/Срок на карточках бэкендов: креденшелы per-profile, поэтому + // операции идут по всем профилям с грантом этого бэкенда. + const grantedBackendProfiles = (kind: 'hysteria2' | 'awg'): ServerProfile[] => + (profiles ?? []).filter((p) => p.backends?.[kind]) + + const revokeBackendKeys = (kind: 'hysteria2' | 'awg'): Promise => { + setConfirmBackend(null) + const targets = grantedBackendProfiles(kind) + return runBackendAction(`${kind}-revoke-all`, async () => { + for (const p of targets) { + if (kind === 'hysteria2') { + await window.api.backends.hysteria2Grant(server.id, access, p.name) + } else { + await window.api.backends.awgGrant(server.id, access, p.name) + } + } + }) + } + + const openBackendExpiry = (kind: 'hysteria2' | 'awg'): void => { + const granted = grantedBackendProfiles(kind) + if (granted.length === 0) return + if (granted.length === 1) { + setExpireChooser(null) + setExpireTarget(granted[0]) + } else { + setExpireChooser(granted) } } + const showKeysQr = async (data: string): Promise => { + setKeysQrUrl(data) + setKeysQrData(null) + const dataUrl = await QRCode.toDataURL(data, { width: 320, margin: 2 }) + setKeysQrData(dataUrl) + } + const forgetHostKey = async (): Promise => { setHostKeyResetBusy(true) setError(null) @@ -549,6 +720,145 @@ export function ServerSettings({ } } + // Создание профиля бэкенда из карточки в «Создать профиль»: + // тот же профиль (имя/количество/срок из общей формы), плюс сразу + // выдаётся ключ выбранного протокола (hysteria2/awg attach). + const createBackendProfiles = async (kind: 'hysteria2' | 'awg'): Promise => { + if (!accessReady) { + setError(t('settings.errorPassword')) + return + } + if (!name.trim()) { + setError(t('settings.errorName')) + return + } + // Pre-check: удаление по имени снимает ВСЕ ключи профиля (маршруты + + // бэкенды), поэтому коллизию имени ловим до создания, а не после. + const nameCollision = futureNames.find((nm) => + (profiles ?? []).some((p) => p.name === nm) + ) + if (nameCollision) { + setError(t('settings.errorNameExists', { name: nameCollision })) + return + } + if (!backends?.backends[kind]?.installed) { + setError(t('settings.backendsCreateNotInstalled')) + return + } + setBusy(true) + setCreating(true) + setError(null) + const beforeNames = new Set((profiles ?? []).map((p) => p.name)) + let createdNames: string[] = [] + let failedMessage: string | null = null + try { + const result = await window.api.profiles.create(server.id, access, { + name: name.trim(), + transport: 'xhttp', + count: Math.min(Math.max(Number(count) || 1, 1), 50), + ...(createExpire && isFutureDate(createExpire, Date.now()) + ? { expire: createExpire } + : {}) + }) + createdNames = result.ok ? result.names : [] + if (!result.ok) failedMessage = result.errors[0] ?? t('settings.createFailed') + } catch (err) { + failedMessage = err instanceof Error ? err.message : String(err) + } + if (createdNames.length === 0 && failedMessage) { + // Профили могли создаться даже при ошибке парсинга — перечитываем список. + try { + const fresh = await window.api.profiles.list(server.id, access) + setProfiles(fresh.profiles ?? []) + const newlyAppeared = (fresh.profiles ?? []).filter( + (p) => futureNames.includes(p.name) && !beforeNames.has(p.name) + ) + if (newlyAppeared.length > 0) { + createdNames = newlyAppeared.map((p) => p.name) + failedMessage = null + } + } catch { + // список не критичен, ошибку создания уже показываем + } + } + const grantErrors: string[] = [] + for (const nm of createdNames) { + try { + if (kind === 'hysteria2') { + await window.api.backends.hysteria2Grant(server.id, access, nm) + } else { + await window.api.backends.awgGrant(server.id, access, nm) + } + } catch (err) { + grantErrors.push(`${nm}: ${err instanceof Error ? err.message : String(err)}`) + } + } + try { + const fresh = await window.api.profiles.list(server.id, access) + setProfiles(fresh.profiles ?? []) + await reloadBackends() + } catch { + // не критично — основной результат уже показан + } + if (createdNames.length > 0 && grantErrors.length === 0) { + toastText(t('settings.backendsCreated', { count: createdNames.length })) + setName('') + setBackendSel(null) + } else if (createdNames.length > 0) { + setError(t('settings.backendsCreatedPartial')) + } else if (failedMessage) { + setError(failedMessage) + } + setBusy(false) + setCreating(false) + } + + // Ключи бэкенд-профиля: карточки в списке профилей (дубликаты стиля + // бэкенд-блока, разделённые по протоколам). + const profileHystLink = async (profile: ServerProfile): Promise => { + const res = await window.api.backends.hysteria2Link(server.id, access, profile.name) + if (!res.ok || !res.link) throw new Error(res.error ?? t('settings.createFailed')) + return res.link + } + + const qrProfileHyst = async (profile: ServerProfile): Promise => { + try { + await showKeysQr(await profileHystLink(profile)) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const profileAwgConf = async (profile: ServerProfile): Promise => { + const res = await window.api.backends.awgConf(server.id, access, profile.name) + if (!res.ok || !res.conf) throw new Error(res.error ?? t('settings.createFailed')) + return res.conf + } + + const qrProfileAwg = async (profile: ServerProfile): Promise => { + try { + await showKeysQr(stripAwgComments(await profileAwgConf(profile))) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const qrProfileAwgVpn = async (profile: ServerProfile): Promise => { + try { + const conf = await profileAwgConf(profile) + await showKeysQr( + await buildAwgVpnUrl(conf, profile.name, { + clientPubKey: profile.backends?.awg?.client_public_key + }) + ) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const hyst = backends?.backends.hysteria2 + const awgEntry = backends?.backends.awg + const transportLabel = (profile: ServerProfile): string => profile.multi_route ? `${profile.transport} · ${profile.routes} ${t('settings.routes')}` : profile.transport @@ -570,18 +880,54 @@ export function ServerSettings({ {server.name} {connected && (
- +
+ + {updateMenuOpen && ( +
+ + + +
+ )} +
} {toast &&
{toast}
} + {keysQrUrl && ( +
setKeysQrUrl(null)} + > + {keysQrData ? QR : null} +
+ )} + {connected && ( <>
{ + if (backendSel) void createBackendProfiles(backendSel) + else void create() + }} > {busy && } {t('settings.createBtn')} @@ -706,7 +1067,7 @@ export function ServerSettings({

{t('settings.protocolPrompt')}

{PROTOCOLS.map((proto) => { - const active = transport === proto.id + const active = transport === proto.id && backendSel === null return (
+
+ {t('settings.backendsTitle')} +

{t('settings.backendsCreateHint')}

+
+ + +
+
@@ -742,7 +1148,8 @@ export function ServerSettings({ )} {profiles!.length === 0 &&
{t('settings.empty')}
} {profiles!.map((profile) => ( -
+ +
{profile.name} @@ -847,12 +1254,368 @@ export function ServerSettings({ )}
+ {profile.backends?.hysteria2 && ( +
+
+ + Hysteria 2 + +
+
{profile.name}
+
+ {t('settings.backendsHyst')} · {t('settings.backendsHystSub')} +
+
+ {hyst?.state === 'active' + ? hyst.sni + ? `${t('settings.backendsActive')} · SNI ${hyst.sni}` + : `${t('settings.backendsActive')} · UDP ${hyst.port ?? '—'}` + : t('settings.backendsNotInstalled')} +
+
+
+
+ + + + +
+
+ )} + {profile.backends?.awg && ( +
+
+ + AmneziaWG + +
+
{profile.name}
+
+ {t('settings.backendsAwg')} · {t('settings.backendsAwgSub')} +
+
+ {awgEntry?.state === 'active' + ? `${t('settings.backendsActive')} · UDP ${awgEntry.port ?? '—'}` + : t('settings.backendsNotInstalled')} +
+
+
+
+ + + + + +
+
+ )} + ))}
+ +
+
+

+ {t('settings.backendsTitle')} + {t('settings.backendsDevBadge')} +

+

{t('settings.backendsHint')}

+

{t('settings.backendsDevNote')}

+ {backendsError !== null ? ( +

{t('settings.backendsUnsupported')}

+ ) : backendsLoading || backends === null ? ( + <> +
+
+ + ) : ( + <> +
+
+
+
+ Hysteria +
+
+
{t('settings.backendsHyst')}
+
{t('settings.backendsHystSub')}
+
+
+
+ {hyst?.installed + ? `${t('settings.backendsActive')} · UDP ${hyst.port ?? '—'} · ${ + hyst.version ?? '' + }` + : t('settings.backendsNotInstalled')} +
+

{t('settings.backendsHystNote')}

+
+ {hyst?.installed ? ( + <> + + + + + + ) : ( + + )} +
+
+ +
+
+
+ Amnezia +
+
+
{t('settings.backendsAwg')}
+
{t('settings.backendsAwgSub')}
+
+
+
+ {awgEntry?.installed + ? `${t('settings.backendsActive')} · UDP ${awgEntry.port ?? '—'} · ${ + awgEntry.three_enabled + ? t('settings.backends31On') + : t('settings.backends31Off') + }` + : t('settings.backendsNotInstalled')} +
+

{t('settings.backendsAwgNote')}

+
+ {awgEntry?.installed ? ( + <> + + + + + + ) : ( + + )} +
+
+
+ + )} +
+
)}
+ { + if (!open) setConfirmBackend(null) + }} + > + + + + + + + + {t('settings.backendsConfirmTitle')} + + + {confirmBackend === 'hysteria2-uninstall' && t('settings.backendsConfirmHyst')} + {confirmBackend === 'awg-uninstall' && t('settings.backendsConfirmAwg')} + {confirmBackend === 'awg31-on' && t('settings.backendsConfirm31On')} + {confirmBackend === 'awg31-off' && t('settings.backendsConfirm31Off')} + {confirmBackend === 'hysteria2-revoke-all' && + t('settings.backendsConfirmRevokeHystAll')} + {confirmBackend === 'awg-revoke-all' && t('settings.backendsConfirmRevokeAwgAll')} + + + + + + + + + + { @@ -988,14 +1751,18 @@ export function ServerSettings({ variant="secondary" isDisabled={revokeBusy} onPress={() => - revokeStep === 'confirm' ? setRevokeStep('choose') : setRevokeTarget(null) + revokeStep === 'confirm' + ? (revokeFullOnly + ? setRevokeTarget(null) + : setRevokeStep('choose')) + : setRevokeTarget(null) } > {t('dashboard.cancel')} {revokeStep === 'confirm' && ( - ))} - - -
- - {expireDate && ( -

- {t('settings.expirePicked', { date: expireDate })} -

- )} -
-

{t('settings.expireEnforced')}

- {/* Снятие срока — отдельное осознанное действие в теле диалога: - раньше оно жило в футере рядом с «Сохранить» и требовало - второй кнопки «Готово», что путало (одно действие — два клика). */} - {expireTarget?.expire ? ( -
- - - {t('settings.expireClearHint')} - + {expireTarget === null && expireChooser !== null ? ( +
+

{t('settings.backendsExpiryChoose')}

+
+ {expireChooser.map((p) => ( + + ))} +
- ) : null} + ) : ( + <> +

{t('settings.expireHintBody')}

+ {expireTarget && ( +

+ {t('settings.expireCurrent', { + value: expireTarget.expire + ? describeExpire( + expireTarget.expire, + false, + Date.now(), + expireTarget.expire_date + ).date + : t('settings.expireNever') + })} +

+ )} +
+ {t('settings.expireSelect')} +
+ {[7, 30, 90, 365].map((d) => ( + + ))} +
+
+
+ + {expireDate && ( +

+ {t('settings.expirePicked', { date: expireDate })} +

+ )} +
+

{t('settings.expireEnforced')}

+ {/* Снятие срока — отдельное осознанное действие в теле диалога: + раньше оно жило в футере рядом с «Сохранить» и требовало + второй кнопки «Готово», что путало (одно действие — два клика). */} + {expireTarget?.expire ? ( +
+ + + {t('settings.expireClearHint')} + +
+ ) : null} + + )} {/* Отмена — слева и всегда только закрывает диалог, без действий. */} diff --git a/src/shared/awg.ts b/src/shared/awg.ts new file mode 100644 index 00000000..aac5c824 --- /dev/null +++ b/src/shared/awg.ts @@ -0,0 +1,213 @@ +/** + * Утилиты разбора клиентского конфига AmneziaWG для GUI. + * GUI показывает конфиг как структурированный «ключ» (Endpoint/Адрес), + * а не простыню текста; полный текст нужен для QR и копирования. + */ + +/** Убирает комментарии-шапку и нормализует CRLF — часть приложений спотыкается о строки до [Interface]. */ +export function stripAwgComments(conf: string): string { + return conf + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n') + .trim() +} + +export interface AwgClientKeyFields { + /** Endpoint = host:port из секции [Peer]. */ + endpoint: string + /** Address = 10.8.1.x/32 клиента. */ + address: string +} + +export function parseAwgClientConf(conf: string): AwgClientKeyFields { + const endpoint = conf.match(/^Endpoint\s*=\s*(.+)$/m)?.[1]?.trim() ?? '' + const address = conf.match(/^Address\s*=\s*(.+)$/m)?.[1]?.trim() ?? '' + return { endpoint, address } +} + +/** + * Карта key=value из клиентского .conf (заголовки секций пропускаются, + * значения тримятся) — зеркало парсера importController в AmneziaVPN. + */ +export function parseAwgConfMap(conf: string): Record { + const map: Record = {} + for (const raw of conf.split('\n')) { + const line = raw.trim() + if (line.startsWith('[') && line.endsWith(']')) continue + const idx = line.indexOf('=') + if (idx > 0) map[line.slice(0, idx).trim()] = line.slice(idx + 1).trim() + } + return map +} + +/** AWG-ключи, которые импортёр AmneziaVPN переносит в last_config (awgProtocolKeys). */ +const AWG_PROTOCOL_KEYS = [ + 'Jc', + 'Jmin', + 'Jmax', + 'S1', + 'S2', + 'S3', + 'S4', + 'H1', + 'H2', + 'H3', + 'H4', + 'I1', + 'I2', + 'I3', + 'I4', + 'I5', + 'HeaderProtectionKey', + 'ContentPaddingAddition', + 'RandomTrailers', + 'DisableCookies', +] as const + +/** + * Версия протокола в vpn:// — всегда 3.1: Xrayebator ставит 3.1 по умолчанию + * и выдаёт ключи только в этом режиме (значение из protocolConstants Amnezia). + */ +const AWG_PROTOCOL_VERSION = '3.1' + +/** + * Строит JSON-объект в родном для AmneziaVPN формате — 1-в-1 с их экспортом + * docker-сервера (AwgProtocolConfig::toJson): серверные junk-поля на уровне + * объекта "awg", range-таймеры и ContentPaddingAddition (это параметры их + * go-туннеля, не kernel), last_config с полным клиентским набором включая + * client_pub_key/clientId. Контейнер — "amnezia-awg2". + */ +export function buildAwgVpnConfig( + conf: string, + description?: string, + opts?: { clientPubKey?: string; mtu?: string } +): Record { + const map = parseAwgConfMap(stripAwgComments(conf)) + const endpoint = map['Endpoint'] ?? '' + const [host = '', port = ''] = endpoint.split(':') + const version = AWG_PROTOCOL_VERSION + const clientIp = (map['Address'] ?? '').split('/')[0] + + const clientPub = opts?.clientPubKey ?? '' + const lastConfig: Record = { + config: stripAwgComments(conf), + hostName: host, + port: Number(port) || 51820, + client_ip: clientIp, + } + if (map['PrivateKey']) lastConfig['client_priv_key'] = map['PrivateKey'] + if (clientPub) { + lastConfig['client_pub_key'] = clientPub + lastConfig['clientId'] = clientPub + } + const psk = map['PresharedKey'] ?? map['PreSharedKey'] + if (psk) lastConfig['psk_key'] = psk + if (map['PublicKey']) lastConfig['server_pub_key'] = map['PublicKey'] + if (map['AllowedIPs']) { + lastConfig['allowed_ips'] = map['AllowedIPs'] + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + // junk + 3.x параметры — зеркально в last_config (как в их экспорте) + for (const key of AWG_PROTOCOL_KEYS) { + if (key === 'RandomTrailers') lastConfig[key] = map[key] ?? 'on' + else if (map[key]) lastConfig[key] = map[key] + } + // range-таймеры go-туннеля — дефолты Amnezia (параметры их туннеля, не wire-формат) + lastConfig['persistent_keep_alive'] = map['PersistentKeepalive'] ?? '25' + lastConfig['RekeyAfterTime'] = '100-120' + lastConfig['RekeyTimeout'] = '3-7' + lastConfig['RejectAfterTime'] = '150-180' + lastConfig['KeepaliveTimeout'] = '5-15' + lastConfig['MaxHandshakeAttempts'] = '15-20' + lastConfig['ContentPaddingAddition'] = '10-100' + lastConfig['mtu'] = opts?.mtu ?? map['MTU'] ?? '1280' + lastConfig['protocol_version'] = version + + // серверный уровень "awg" — тот же набор junk + subnet_address (как в экспорте) + const serverLevel: Record = { + port, + transport_proto: 'udp', + protocol_version: version, + Jc: map['Jc'] ?? '', + Jmin: map['Jmin'] ?? '', + Jmax: map['Jmax'] ?? '', + S1: map['S1'] ?? '', + S2: map['S2'] ?? '', + } + for (const key of ['S3', 'S4', 'H1', 'H2', 'H3', 'H4'] as const) { + if (map[key]) serverLevel[key] = map[key] + } + for (const key of ['I1', 'I2', 'I3', 'I4', 'I5'] as const) { + serverLevel[key] = map[key] ?? '' + } + if (map['HeaderProtectionKey']) serverLevel['HeaderProtectionKey'] = map['HeaderProtectionKey'] + if (map['RandomTrailers']) serverLevel['RandomTrailers'] = map['RandomTrailers'] + if (map['DisableCookies']) serverLevel['DisableCookies'] = map['DisableCookies'] + serverLevel['RekeyAfterTime'] = '100-120' + serverLevel['RekeyTimeout'] = '3-7' + serverLevel['RejectAfterTime'] = '150-180' + serverLevel['KeepaliveTimeout'] = '5-15' + serverLevel['MaxHandshakeAttempts'] = '15-20' + serverLevel['ContentPaddingAddition'] = '10-100' + serverLevel['subnet_address'] = '10.8.1.0' + serverLevel['isThirdPartyConfig'] = true + serverLevel['last_config'] = JSON.stringify(lastConfig) + + const container: Record = { + container: 'amnezia-awg2', + awg: serverLevel, + } + + const root: Record = { + containers: [container], + defaultContainer: 'amnezia-awg2', + description: description ?? 'AmneziaWG', + hostName: host, + } + const dns = (map['DNS'] ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + if (dns[0]) root['dns1'] = dns[0] + if (dns[1]) root['dns2'] = dns[1] + return root +} + +/** + * qCompress в форме AmneziaVPN: 4 байта BE-размера + zlib-поток. + * CompressionStream('deflate') — тот же zlib-контейнер (RFC 1950), что + * создаёт их Qt-экспорт; доступен в Electron-renderer и Node 18+. + */ +async function qCompressBase64Url(json: string): Promise { + const bytes = new TextEncoder().encode(json) + const head = new Uint8Array(4) + new DataView(head.buffer).setUint32(0, bytes.length) + const body = new Uint8Array( + await new Response( + new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate')) + ).arrayBuffer() + ) + const payload = new Uint8Array(4 + body.length) + payload.set(head, 0) + payload.set(body, 4) + let bin = '' + for (const b of payload) bin += String.fromCharCode(b) + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Родная ссылка-импорт для AmneziaVPN: vpn:// + base64url(qCompress(JSON)) — + * ровно та же форма, что их собственный «Поделиться». + */ +export async function buildAwgVpnUrl( + conf: string, + description?: string, + opts?: { clientPubKey?: string; mtu?: string } +): Promise { + const json = JSON.stringify(buildAwgVpnConfig(conf, description, opts)) + return 'vpn://' + (await qCompressBase64Url(json)) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e8f25f22..88bff6dc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -12,7 +12,19 @@ export type XrayState = 'running' | 'stopped' | 'missing' | 'unknown' export type ProfilesState = 'available' | 'empty' | 'missing' | 'unknown' -export type SubscriptionState = 'public' | 'localOnly' | 'missing' | 'unreachable' | 'unknown' +/** + * Состояние подписки с сервера (inspect/normalize): + * public — HTTPS endpoint работает; fallback — http_tls-режим (endpoint с + * server'а работает, из интернета может быть недоступен: hoster-фильтр :80); + * localOnly — только 127.0.0.1; missing/unreachable — проблемные. + */ +export type SubscriptionState = + | 'public' + | 'fallback' + | 'localOnly' + | 'missing' + | 'unreachable' + | 'unknown' export interface PrivateKeyReference { credentialId: string @@ -91,6 +103,13 @@ export interface Server { routesCount: number | null subscriptionUrl: string keys: VlessLink[] + /** + * Мультипротокольный этап: hysteria2-ссылки из тела подписки — персистятся + * вместе с vless-ключами, чтобы страница «Ключи» открывалась мгновенно. + */ + hysteria2Keys?: string[] + /** Кэш клиентских .conf AWG per-profile (имя профиля → конфиг). */ + awgConfs?: Record authMethod?: SshAuthMethod privilegeMode?: SshPrivilegeMode privateKeyPath?: string | null @@ -102,6 +121,12 @@ export interface Server { setupStatus?: ServerSetupStatus diagnostics?: ServerDiagnostics | null hostKeyFingerprint?: string | null + /** + * http_tls-fallback (quickstart при hoster-блокировке http-01): подписка + * работает с server'а, но публичного HTTPS нет. Карточка помечается + * «Настроен частично», Keys грузит ключи по SSH. + */ + degraded?: boolean } export interface VlessLink { @@ -114,6 +139,103 @@ export interface SubscriptionResult { serverId: string subscriptionUrl: string keys: VlessLink[] + /** Строки hysteria2:// из тела подписки (UDP-бэкенд). */ + hysteria2Links?: string[] +} + +/** Грант Hysteria 2 в профиле (username = имя профиля, password = 32 hex). */ +export interface ProfileHysteria2Grant { + username: string + password: string + created?: string +} + +/** Грант AmneziaWG peer-а в профиле (адрес 10.8.1.x, full-tunnel клиент). */ +export interface ProfileAwgGrant { + client_private_key: string + client_public_key: string + preshared_key: string + address: string + created?: string +} + +/** + * Аддитивный объект бэкенд-грантов в профиле. Профиль — единственный источник + * правды: revoke/expire на сервере синхронно меняют эти креденшелы. + * Пометка для UI: AWG-грант работает через клиент AmneziaVPN, НЕ через V2Ray/HAPP. + */ +export interface ProfileBackends { + hysteria2?: ProfileHysteria2Grant + awg?: ProfileAwgGrant +} + +/** Запись одного бэкенда в реестре сервера (backend-status). */ +export interface BackendEntry { + installed: boolean + /** active | inactive | unknown (unknown — юнит не задан). */ + state: string + version?: string + port?: number + unit?: string + /** Hysteria 2: le | selfsigned. */ + tls_mode?: string + sni?: string + masquerade?: string + /** Hysteria 2: строки hysteria2:// в телах подписки (kill-switch). */ + sub_body?: boolean + /** AWG 3.1: HeaderProtectionKey + RandomTrailers. */ + three_enabled?: boolean + /** AWG 3.1: локальный флаг отключения cookie-механизма. */ + disable_cookies?: boolean + subnet?: string +} + +export interface BackendStatusResult { + ok: boolean + backends: Record + error?: string +} + +export interface BackendGrantResult { + ok: boolean + name?: string + error?: string +} + +/** Результат install/uninstall бэкенда (rc=2 воркера → already). */ +export interface BackendSimpleResult { + ok: boolean + already?: boolean + error?: string +} + +export interface Hysteria2SubbodyResult { + ok: boolean + sub_body?: boolean + error?: string +} + +export interface Hysteria2LinkResult { + ok: boolean + name?: string + /** hysteria2://user:pass@host:port/?sni=…&insecure=…#🇩🇪 Country · name */ + link?: string + error?: string +} + +export interface AwgConfResult { + ok: boolean + name?: string + /** Полный текст клиентского .conf (секреты уровня оператора). */ + conf?: string + error?: string +} + +export interface BackendToggleResult { + ok: boolean + three_enabled?: boolean + disable_cookies?: boolean + error?: string } export interface ServerProfile { @@ -141,6 +263,11 @@ export interface ServerProfile { * показывал бы бессрочность там, где управление сроком недоступно. */ expire_supported?: boolean + /** + * Мультипротокольный этап: гранты опциональных бэкендов. Пустой объект или + * отсутствие поля — профиль не имеет доступа к hysteria2/awg. + */ + backends?: ProfileBackends } export interface ProfileCreateInput { @@ -365,13 +492,80 @@ export interface ElectronAPI { ) => Promise } server: { + /** + * Обновить менеджер на сервере. Без branch — закреплённая на сервере ветка + * (.current_branch) или main. Явная ветка (main | dev | experimental) — + * управляемая бета: обновиться с dev, не дожидаясь вливания в main. + */ update: ( serverId: string, - access: SshAccessInput + access: SshAccessInput, + branch?: 'main' | 'dev' | 'experimental' ) => Promise uninstall: ( serverId: string, access: SshAccessInput ) => Promise } + backends: { + /** Статус всех бэкендов из реестра сервера (backend-status). */ + status: ( + serverId: string, + access: SshAccessInput + ) => Promise + hysteria2Grant: ( + serverId: string, + access: SshAccessInput, + name: string + ) => Promise + /** Установка Hysteria 2 (длинная операция). */ + hysteria2Install: ( + serverId: string, + access: SshAccessInput, + grantAll: boolean + ) => Promise + hysteria2Uninstall: ( + serverId: string, + access: SshAccessInput + ) => Promise + /** Kill-switch hysteria2-строк в подписке. */ + hysteria2Subbody: ( + serverId: string, + access: SshAccessInput, + on: boolean + ) => Promise + /** hysteria2:// ссылка профиля (требует грант и установленный бэкенд). */ + hysteria2Link: ( + serverId: string, + access: SshAccessInput, + name: string + ) => Promise + awgGrant: ( + serverId: string, + access: SshAccessInput, + name: string + ) => Promise + /** Установка AmneziaWG (может собирать kernel-модуль — минуты). */ + awgInstall: ( + serverId: string, + access: SshAccessInput, + grantAll: boolean + ) => Promise + awgUninstall: ( + serverId: string, + access: SshAccessInput + ) => Promise + /** Клиентский .conf AWG peer-а для профиля ( AmneziaVPN, не V2Ray/HAPP ). */ + awgConf: ( + serverId: string, + access: SshAccessInput, + name: string + ) => Promise + /** Тумблер AWG 3.1: включение/выключение требует перекачки .conf клиентов. */ + awg31: ( + serverId: string, + access: SshAccessInput, + on: boolean + ) => Promise + } } diff --git a/tests/unit/awg-shared.test.ts b/tests/unit/awg-shared.test.ts new file mode 100644 index 00000000..c6cfc2e6 --- /dev/null +++ b/tests/unit/awg-shared.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { inflateSync } from 'node:zlib' +import { + buildAwgVpnConfig, + buildAwgVpnUrl, + parseAwgClientConf, + parseAwgConfMap, + stripAwgComments +} from '../../src/shared/awg' + +const CONF = [ + '# Xrayebator: транспорт AmneziaWG (AWG 3.1) — НЕ через V2Ray/HAPP.', + '# Профиль: happ.', + '[Interface]', + 'PrivateKey = PRIV=', + 'Address = 10.8.1.2/32', + 'HeaderProtectionKey = HPK=', + '', + '[Peer]', + 'PublicKey = SERVERPUB=', + 'Endpoint = 2.26.125.147:45467', + 'AllowedIPs = 0.0.0.0/0, ::/0' +].join('\n') + +describe('stripAwgComments', () => { + it('убирает комментарии-шапку и сохраняет секции', () => { + const clean = stripAwgComments(CONF) + expect(clean.startsWith('[Interface]')).toBe(true) + expect(clean).not.toContain('# Xrayebator') + expect(clean).toContain('[Peer]') + expect(clean).toContain('Endpoint = 2.26.125.147:45467') + }) +}) + +describe('parseAwgClientConf', () => { + it('извлекает Endpoint и Address как поля ключа', () => { + const fields = parseAwgClientConf(CONF) + expect(fields.endpoint).toBe('2.26.125.147:45467') + expect(fields.address).toBe('10.8.1.2/32') + }) + + it('возвращает пустые поля для мусорного конфига', () => { + const fields = parseAwgClientConf('мусор') + expect(fields.endpoint).toBe('') + expect(fields.address).toBe('') + }) +}) + +const FULL_CONF = [ + '[Interface]', + 'PrivateKey = PRIVKEYBASE64=', + 'Address = 10.8.1.2/32', + 'DNS = 1.1.1.1, 1.0.0.1', + 'MTU = 1280', + 'Jc = 5', + 'Jmin = 10', + 'Jmax = 50', + 'S1 = 146', + 'S2 = 120', + 'S3 = 49', + 'S4 = 12', + 'H1 = 1', + 'H2 = 2', + 'H3 = 3', + 'H4 = 4', + 'HeaderProtectionKey = HPKBASE64=', + 'RandomTrailers = on', + '', + '[Peer]', + 'PublicKey = SERVERPUB=', + 'PresharedKey = PSKBASE64=', + 'AllowedIPs = 0.0.0.0/0, ::/0', + 'Endpoint = 2.26.125.147:45467', + 'PersistentKeepalive = 25' +].join('\n') + +describe('parseAwgConfMap', () => { + it('разбирает key=value и пропускает секции', () => { + const map = parseAwgConfMap(FULL_CONF) + expect(map['Jc']).toBe('5') + expect(map['Endpoint']).toBe('2.26.125.147:45467') + expect(map['PresharedKey']).toBe('PSKBASE64=') + expect(Object.keys(map).some((k) => k.startsWith('['))).toBe(false) + }) +}) + +describe('buildAwgVpnUrl', () => { + it('строит сжатый vpn:// с контейнером amnezia-awg2 и полным last_config', async () => { + const url = await buildAwgVpnUrl(FULL_CONF, 'happ', { + clientPubKey: 'CLIENTPUB=' + }) + expect(url.startsWith('vpn://')).toBe(true) + const payload64 = url.slice('vpn://'.length) + expect(payload64).not.toContain('+') + expect(payload64).not.toContain('/') + expect(payload64.endsWith('=')).toBe(false) + + // qCompress: 4 байта BE-размера + zlib + const raw = Buffer.from(payload64, 'base64') + expect(raw.readUInt32BE(0)).toBeGreaterThan(0) + const payload = JSON.parse( + inflateSync(raw.subarray(4)).toString('utf8') + ) as Record + expect(payload['defaultContainer']).toBe('amnezia-awg2') + expect(payload['hostName']).toBe('2.26.125.147') + expect(payload['dns1']).toBe('1.1.1.1') + + const containers = payload['containers'] as Array> + expect(containers[0]['container']).toBe('amnezia-awg2') + const awg = containers[0]['awg'] as Record + // серверные junk-поля дублируются на уровне awg-объекта (AwgServerConfig::toJson) + expect(awg['Jc']).toBe('5') + expect(awg['S1']).toBe('146') + expect(awg['H1']).toBe('1') + expect(awg['I1']).toBe('') + expect(awg['protocol_version']).toBe('3.1') + expect(awg['subnet_address']).toBe('10.8.1.0') + expect(awg['RekeyAfterTime']).toBe('100-120') + expect(awg['ContentPaddingAddition']).toBe('10-100') + expect(awg['isThirdPartyConfig']).toBe(true) + expect(awg['transport_proto']).toBe('udp') + + const last = JSON.parse(awg['last_config'] as string) as Record + expect(last['protocol_version']).toBe('3.1') + expect(last['client_ip']).toBe('10.8.1.2') + expect(last['client_priv_key']).toBe('PRIVKEYBASE64=') + expect(last['client_pub_key']).toBe('CLIENTPUB=') + expect(last['clientId']).toBe('CLIENTPUB=') + expect(last['psk_key']).toBe('PSKBASE64=') + expect(last['server_pub_key']).toBe('SERVERPUB=') + expect(last['port']).toBe(45467) + expect(last['allowed_ips']).toEqual(['0.0.0.0/0', '::/0']) + expect(last['config']).not.toContain('#') + expect(last['RekeyAfterTime']).toBe('100-120') + expect(last['RandomTrailers']).toBe('on') + }) + + it('выживает кириллицу в имени профиля (UTF-8 → qCompress → base64url)', async () => { + const url = await buildAwgVpnUrl(FULL_CONF, 'Тест-профиля') + const raw = Buffer.from(url.slice(6), 'base64') + const payload = JSON.parse( + inflateSync(raw.subarray(4)).toString('utf8') + ) as Record + expect(payload['description']).toBe('Тест-профиля') + }) + + it('всегда ставит protocol_version 3.1 и добавляет range-поля даже без HPK', async () => { + const minimal = [ + '[Interface]', + 'PrivateKey = P=', + 'Address = 10.8.1.2/32', + '[Peer]', + 'PublicKey = S=', + 'Endpoint = 1.2.3.4:51820' + ].join('\n') + const cfg = buildAwgVpnConfig(minimal) + expect(cfg['defaultContainer']).toBe('amnezia-awg2') + const awg = (cfg['containers'] as Array>)[0]['awg'] as Record< + string, + unknown + > + expect(awg['protocol_version']).toBe('3.1') + expect(awg['RekeyAfterTime']).toBe('100-120') + const last = JSON.parse(awg['last_config'] as string) as Record + expect(last['protocol_version']).toBe('3.1') + expect('HeaderProtectionKey' in last).toBe(false) + }) +}) diff --git a/tests/unit/backend-manager.test.ts b/tests/unit/backend-manager.test.ts new file mode 100644 index 00000000..192bc56c --- /dev/null +++ b/tests/unit/backend-manager.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { + parseAwgConf, + parseBackendStatus +} from '../../src/main/core/backend-manager' + +describe('parseBackendStatus', () => { + it('разбирает полный статус с обоими бэкендами и флагами 3.1', () => { + const raw = JSON.stringify({ + ok: true, + backends: { + hysteria2: { + installed: true, + state: 'active', + version: 'v2.12.3', + port: 443, + unit: 'hysteria-server.service', + tls_mode: 'le 2.26.125.147', + sni: '2.26.125.147', + masquerade: '404', + obfs: false, + sub_body: true + }, + awg: { + installed: true, + state: 'active', + version: 'kernel-dkms', + port: 45467, + unit: 'awg-quick@awg0.service', + subnet: '10.8.1.0/24', + mtu: 1280, + iface: 'ens3', + sub_body: false, + three_enabled: true, + disable_cookies: false + } + } + }) + const result = parseBackendStatus(raw) + expect(result.ok).toBe(true) + expect(result.backends.hysteria2?.state).toBe('active') + expect(result.backends.hysteria2?.port).toBe(443) + expect(result.backends.hysteria2?.sub_body).toBe(true) + expect(result.backends.awg?.three_enabled).toBe(true) + expect(result.backends.awg?.disable_cookies).toBe(false) + expect(result.backends.awg?.port).toBe(45467) + }) + + it('пустой реестр — ok=true, backends пуст, бэкенды не установлены', () => { + const result = parseBackendStatus('{"ok":true,"backends":{}}') + expect(result.ok).toBe(true) + expect(Object.keys(result.backends)).toHaveLength(0) + expect(result.backends.hysteria2).toBeUndefined() + }) + + it('вычищает ANSI-статусы перед JSON (CLI печатает цветные статусы)', () => { + const raw = + '\u001b[0;36m → Порт 443/udp открыт\u001b[0m\n' + + '{"ok":true,"backends":{"hysteria2":{"installed":true,"state":"active"}}}' + const result = parseBackendStatus(raw) + expect(result.ok).toBe(true) + expect(result.backends.hysteria2?.installed).toBe(true) + }) + + it('ошибка реестра — ok=false и error сохраняется', () => { + const result = parseBackendStatus( + '{"ok":false,"error":"registry_init_failed","backends":{}}' + ) + expect(result.ok).toBe(false) + expect(result.error).toBe('registry_init_failed') + }) + + it('некорректный JSON бросает ошибку (extractJson контракт)', () => { + expect(() => parseBackendStatus('вышла ошибка без json')).toThrow() + }) +}) + +describe('parseAwgConf', () => { + it('разбирает клиентский .conf с 3.1-ключами', () => { + const conf = [ + '[Interface]', + 'PrivateKey = CLIENTPRIV=', + 'Address = 10.8.1.2/32', + 'MTU = 1280', + 'S3 = 37', + 'S4 = 28', + 'HeaderProtectionKey = k0Y0I/kkRqj/2ORku3BPD2b2PRMF2c8VnpPVNo4MPn8=', + 'RandomTrailers = on', + '', + '[Peer]', + 'PublicKey = SERVERPUB=', + 'AllowedIPs = 0.0.0.0/0, ::/0', + 'Endpoint = 2.26.125.147:45467', + 'PersistentKeepalive = 25' + ].join('\n') + const raw = JSON.stringify({ ok: true, name: 'happ', conf }) + const result = parseAwgConf(raw) + expect(result.ok).toBe(true) + expect(result.name).toBe('happ') + expect(result.conf).toContain('HeaderProtectionKey = ') + expect(result.conf).toContain('RandomTrailers = on') + expect(result.conf).toContain('Endpoint = 2.26.125.147:45467') + }) + + it('отсутствие гранта — ok=false с сообщением сервера', () => { + const raw = JSON.stringify({ + ok: false, + error: 'Нет peer-гранта AWG для профиля happ' + }) + const result = parseAwgConf(raw) + expect(result.ok).toBe(false) + expect(result.conf).toBeUndefined() + expect(result.error).toContain('peer-гранта') + }) +}) diff --git a/tests/unit/server-inspector.test.ts b/tests/unit/server-inspector.test.ts index 6f559abb..6e7f8e77 100644 --- a/tests/unit/server-inspector.test.ts +++ b/tests/unit/server-inspector.test.ts @@ -106,3 +106,25 @@ describe('normalizeInspection', () => { ).toBeNull() }) }) + +describe('http_tls fallback (hoster-блокировка http-01)', () => { + const fallbackInspection: InspectionSnapshot = { + ...baseInspection, + subscription_mode: 'http_tls', + subscription_url: 'http://203.0.113.10:8080/sub/token' + } + + it('treats http_tls as a working fallback subscription, not a public one', () => { + const result = normalizeInspection(fallbackInspection, routes) + expect(result.diagnostics.subscription).toBe('fallback') + // Мёртвый публичный URL не сохраняется: снаружи на него никто не слушает, + // реальный канал доставки ключей — SSH. + expect(result.subscriptionUrl).toBe('') + expect(result.keys).toHaveLength(1) + expect(result.setupStatus).toBe('partial') + }) + + it('never probes the http_tls endpoint from the user network', () => { + expect(subscriptionProbeTarget(fallbackInspection)).toBeNull() + }) +}) diff --git a/validation/test-awg-lifecycle.sh b/validation/test-awg-lifecycle.sh new file mode 100644 index 00000000..194c27b2 --- /dev/null +++ b/validation/test-awg-lifecycle.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Тест backend AmneziaWG 2.0 — срез 4 (2026-10-04). +# Функционально: junk-параметры (диапазоны спеки AWG 2.0), рендер серверного +# конфига, keygen через фейковый awg, реестр/статус. Полная установка (PPA/ +# DKMS/сборка) и поднятие интерфейса — живой спайк на VPS (срез 7). +set -u +cd "$(dirname "$0")/.." + +fail() { echo "✗ $1"; exit 1; } +pass() { echo "✓ $1"; } + +command -v jq >/dev/null 2>&1 || fail "jq required (CI installs it)" +command -v openssl >/dev/null 2>&1 || fail "openssl required" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT +export BACKENDS_ROOT="$TMP_ROOT/xrayebator-backends" +export LOCK_FILE="$TMP_ROOT/xrayebator.lock" +export PROFILES_DIR="$TMP_ROOT/profiles" +mkdir -p "$PROFILES_DIR" + +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" +cat > "$FAKEBIN/systemctl" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +cat > "$FAKEBIN/awg" <<'EOF' +#!/usr/bin/env bash +case "$1" in + genkey) openssl rand -base64 32 ;; + genpsk) openssl rand -base64 32 ;; + pubkey) cat >/dev/null; openssl rand -base64 32 ;; + show) exit 0 ;; + *) exit 0 ;; +esac +EOF +cat > "$FAKEBIN/awg-quick" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod 755 "$FAKEBIN"/systemctl "$FAKEBIN"/awg "$FAKEBIN"/awg-quick +export PATH="$FAKEBIN:$PATH" +export XRAYEBATOR_SERVER_ADDR_OVERRIDE="203.0.113.10" + +# shellcheck disable=SC1091 +source ./xrayebator || fail "source ./xrayebator failed" + +# 1) junk-параметры: дефолтный диалект Amnezia (совместимость с их мобильными +# клиентами) + уникальность S1..S4 (5 прогонов), включая 3.1-поля +for _ in 1 2 3 4 5; do + junk=$(_awg_gen_junk) + jq -e ' + .Jc == 5 and .Jmin == 10 and .Jmax == 50 and + .S1 >= 12 and .S1 <= 150 and .S2 >= 12 and .S2 <= 150 and + .S3 >= 12 and .S3 <= 64 and .S4 >= 12 and .S4 <= 64 and + ([.S1, .S2, .S3, .S4] | (length == ([unique[]] | length))) and + (.S1 + 56) != .S2 and + .H1 == 1 and .H2 == 2 and .H3 == 3 and .H4 == 4 and + (.HeaderProtectionKey | length == 44 and endswith("=")) + ' <<<"$junk" >/dev/null || fail "junk/3.1 params out of spec: $junk" +done +pass "junk = Amnezia defaults (Jc=5/Jmin=10/Jmax=50/H=1..4) + S1-S4 unique + HPK (5/5 runs)" + +# 1b) _awg_ensure_31_params дополняет старый params (до-3.1 установка) +old_junk=$(jq -c 'del(.S3, .S4, .HeaderProtectionKey)' <<<"$junk") +upgraded=$(_awg_ensure_31_params "$old_junk") || fail "ensure_31_params" +jq -e 'has("S3") and has("S4") and (.S3 >= 12) and (.S4 >= 12) and + (.HeaderProtectionKey | length == 44)' <<<"$upgraded" >/dev/null \ + || fail "ensure_31_params did not add 3.1 fields: $upgraded" +pass "_awg_ensure_31_params upgrades legacy params with S3/S4/HPK" + +# 2) keygen через awg (фейковый): три непустых base64 +keys=$(_awg_gen_keys) || fail "awg gen keys" +read -r priv pub psk <<<"$keys" +[[ -n "$priv" && -n "$pub" && -n "$psk" ]] || fail "keys empty" +pass "awg genkey/pubkey/genpsk produce key triple" + +# 3) рендер серверного конфига: 2.0-режим (без фич) и 3.1-режим +junk=$(_awg_gen_junk) +conf="$TMP_ROOT/awg0.conf" +_awg_render_server_conf "$conf" 51820 "$priv" "$junk" "eth0" '{}' || fail "render server conf" +grep -q '^Address = 10.8.1.1/24$' "$conf" || fail "conf Address" +grep -q '^ListenPort = 51820$' "$conf" || fail "conf ListenPort" +grep -qF "PrivateKey = $priv" "$conf" || fail "conf PrivateKey" +grep -q '^MTU = 1280$' "$conf" || fail "conf MTU" +grep -qF "Jc = $(jq -r .Jc <<<"$junk")" "$conf" || fail "conf Jc" +grep -qF "S3 = $(jq -r .S3 <<<"$junk")" "$conf" || fail "conf S3" +grep -qF "S4 = $(jq -r .S4 <<<"$junk")" "$conf" || fail "conf S4" +grep -qF "H4 = $(jq -r .H4 <<<"$junk")" "$conf" || fail "conf H4" +grep -qF "PostUp = iptables -A FORWARD -i awg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE" "$conf" || fail "conf PostUp" +grep -qF "PostDown = iptables -D FORWARD -i awg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE" "$conf" || fail "conf PostDown" +! grep -q '^\[Peer\]' "$conf" || fail "conf must have no peers at install" +! grep -q 'HeaderProtectionKey' "$conf" || fail "2.0 render must not emit HPK" +! grep -q 'RandomTrailers' "$conf" || fail "2.0 render must not emit RandomTrailers" +pass "server conf rendered (2.0 mode: S3/S4 present, no 3.x keys)" + +_awg_render_server_conf "$conf" 51820 "$priv" "$junk" "eth0" '{"hpk":true,"rt":true,"dc":true}' \ + || fail "render server conf 3.1" +grep -qF "HeaderProtectionKey = $(jq -r .HeaderProtectionKey <<<"$junk")" "$conf" || fail "conf HPK" +grep -q '^RandomTrailers = on$' "$conf" || fail "conf RandomTrailers" +grep -q '^DisableCookies = on$' "$conf" || fail "conf DisableCookies" +pass "server conf 3.1 mode emits HeaderProtectionKey/RandomTrailers/DisableCookies" + +# 4) реестр + awg-status +_backend_set awg '(.awg.installed) = true' || fail "set installed" +_backend_set awg '(.awg.port) = $p' --argjson p 51820 || fail "set port" +_backend_set awg '(.awg.unit) = $u' --arg u "awg-quick@awg0.service" || fail "set unit" +_backend_set awg '(.awg.subnet) = $s' --arg s "10.8.1.0/24" || fail "set subnet" +status="$(awg_status_command)" || fail "awg_status_command" +jq -e '.ok == true and .backend.installed == true and .backend.port == 51820 and + .backend.subnet == "10.8.1.0/24"' <<<"$status" >/dev/null \ + || fail "awg status shape: $status" +pass "awg-status JSON shape correct" + +# ── Срез 5: peers, адреса, клиентский .conf, события жизненного цикла ── +# Каталог и server-params в реальном цикле пишет _awg_install — собираем вручную. +mkdir -p "$AWG_DIR" +jq -n --arg priv "$priv" --arg pub "$pub" --argjson port 51820 --arg iface "eth0" \ + --argjson junk "$junk" --arg subnet "10.8.1.0/24" --argjson mtu 1280 \ + '{private_key:$priv, public_key:$pub, port:$port, iface:$iface, junk:$junk, subnet:$subnet, mtu:$mtu}' \ + > "$AWG_PARAMS_FILE" || fail "fixture server-params" + +jq -n '{name:"carol", uuid:"u-carol", transport:"tcp", port:443}' > "$PROFILES_DIR/carol.json" || fail "fixture carol" +_awg_grant_profile carol || fail "awg grant carol" +addr1=$(jq -r '.backends.awg.address' "$PROFILES_DIR/carol.json") +[[ "$addr1" == "10.8.1.2" ]] || fail "first address expected 10.8.1.2, got $addr1" +cpub1=$(jq -r '.backends.awg.client_public_key' "$PROFILES_DIR/carol.json") +grep -qF "AllowedIPs = 10.8.1.2/32" "$AWG_CONF_FILE" || fail "peer AllowedIPs missing in awg0.conf" +grep -qF "PublicKey = $cpub1" "$AWG_CONF_FILE" || fail "peer pubkey missing in awg0.conf" +pass "awg grant allocates first free address (.2) and regenerates [Peer] section" + +conf="$(awg_conf_command --name carol)" || fail "awg-conf command" +jq -e '.ok == true and .name == "carol" and + (.conf | contains("[Interface]")) and (.conf | contains("[Peer]")) and + (.conf | contains("AllowedIPs = 0.0.0.0/0, ::/0")) and + (.conf | contains("Endpoint = 203.0.113.10:51820")) and + (.conf | contains("PersistentKeepalive = 25")) and + (.conf | contains("PresharedKey = ")) and + (.conf | contains("HeaderProtectionKey = ")) and + (.conf | contains("RandomTrailers = on")) and + (.conf | startswith("# Xrayebator:")) and + (.conf | contains("НЕ через"))' <<<"$conf" >/dev/null \ + || fail "client conf shape: $conf" +jq -r '.conf' <<<"$conf" > "$TMP_ROOT/carol-client.conf" +grep -qF "H1 = $(jq -r '.junk.H1' "$AWG_PARAMS_FILE")" "$TMP_ROOT/carol-client.conf" \ + || fail "client conf junk params mismatch with server" +grep -qF "S3 = $(jq -r '.junk.S3' "$AWG_PARAMS_FILE")" "$TMP_ROOT/carol-client.conf" \ + || fail "client conf S3 mismatch with server" +grep -qF "HeaderProtectionKey = $(jq -r '.junk.HeaderProtectionKey' "$AWG_PARAMS_FILE")" "$TMP_ROOT/carol-client.conf" \ + || fail "client conf HPK mismatch with server" +grep -qF "PublicKey = $(jq -r '.public_key' "$AWG_PARAMS_FILE")" "$TMP_ROOT/carol-client.conf" \ + || fail "client conf server pubkey mismatch" +! grep -q '^DisableCookies' "$TMP_ROOT/carol-client.conf" \ + || fail "client conf must not emit DisableCookies when dc=false" +pass "client .conf carries keys, S1-S4+HPK/RandomTrailers, server peer and endpoint" + +# 3.1 toggle: off -> 2.0-формат, on -> 3.1 возвращается +_awg_31_toggle off "" >/dev/null 2>&1 || fail "awg-31 toggle off" +grep -q '^RandomTrailers = on$' "$AWG_CONF_FILE" && fail "toggle off: RandomTrailers still present" +! grep -q 'HeaderProtectionKey' "$AWG_CONF_FILE" || fail "toggle off: HPK still present" +_awg_31_toggle on "" >/dev/null 2>&1 || fail "awg-31 toggle on" +grep -qF "HeaderProtectionKey = $(jq -r '.junk.HeaderProtectionKey' "$AWG_PARAMS_FILE")" "$AWG_CONF_FILE" \ + || fail "toggle on: HPK missing" +grep -q '^RandomTrailers = on$' "$AWG_CONF_FILE" || fail "toggle on: RandomTrailers missing" +pass "3.1 toggle switches conf format and preserves the shared HPK" + +# второй профиль → следующий адрес .3 +jq -n '{name:"dave", uuid:"u-dave", transport:"tcp", port:443}' > "$PROFILES_DIR/dave.json" || fail "fixture dave" +_awg_grant_profile dave || fail "awg grant dave" +addr2=$(jq -r '.backends.awg.address' "$PROFILES_DIR/dave.json") +[[ "$addr2" == "10.8.1.3" ]] || fail "second address expected 10.8.1.3, got $addr2" +pass "address allocation advances (.2 -> .3)" + +# revoked: ротация ключей peer-а +k1=$(jq -r '.backends.awg.client_private_key' "$PROFILES_DIR/carol.json") +_awg_on_profile_event carol revoked || fail "awg event revoked" +k2=$(jq -r '.backends.awg.client_private_key' "$PROFILES_DIR/carol.json") +[[ "$k2" != "$k1" ]] || fail "awg revoke did not rotate keys" +grep -qF "PublicKey = $(jq -r '.backends.awg.client_public_key' "$PROFILES_DIR/carol.json")" "$AWG_CONF_FILE" \ + || fail "awg revoke: conf not updated" +[[ "$(jq -r '.backends.awg.address' "$PROFILES_DIR/carol.json")" == "10.8.1.2" ]] \ + || fail "awg revoke must keep the address" +pass "event revoked rotates peer keys, keeps address, regenerates conf" + +# expired: expire_disabled=true убирает peer из конфига +safe_jq_write '.expire_disabled = true' "$PROFILES_DIR/carol.json" || fail "fixture expire" +_awg_on_profile_event carol expired || fail "awg event expired" +grep -qF "10.8.1.2/32" "$AWG_CONF_FILE" && fail "expired peer still in conf" +grep -qF "10.8.1.3/32" "$AWG_CONF_FILE" || fail "active peer lost on expired event" +pass "event expired removes disabled peer, keeps active ones" + +# restored: возврат peer-а +safe_jq_write '.expire_disabled = false' "$PROFILES_DIR/carol.json" || fail "fixture restore" +_awg_on_profile_event carol restored || fail "awg event restored" +grep -qF "10.8.1.2/32" "$AWG_CONF_FILE" || fail "restored peer missing in conf" +pass "event restored returns peer to conf" + +# deleted: файл профиля удалён ДО события (как в profile_delete_command) — +# реген обязан сняться с интерфейса без удалённого peer-а, иначе осиротевший +# peer живёт вечно и удалённый пользователь сохраняет доступ. +rm -f "$PROFILES_DIR/carol.json" || fail "fixture delete carol" +_awg_on_profile_event carol deleted || fail "awg event deleted (file pre-removed)" +grep -qF "10.8.1.2/32" "$AWG_CONF_FILE" && fail "deleted peer still in conf (regen skipped)" +grep -qF "10.8.1.3/32" "$AWG_CONF_FILE" || fail "active peer lost on deleted event" +pass "event deleted (file pre-removed) removes peer — no orphans" + +# created: авто-выдача ключей при создании отключена — ключ бэкенда +# выдаётся только явным grant (выбор протокола в GUI / меню 11-13). +jq -n '{name:"eve", uuid:"u-eve", transport:"tcp", port:443}' > "$PROFILES_DIR/eve.json" \ + || fail "fixture eve" +_awg_on_profile_event eve created || fail "awg event created" +jq -e '.backends.awg == null' "$PROFILES_DIR/eve.json" >/dev/null \ + || fail "created must not auto-grant awg keys" +pass "event created is a no-op — keys only via explicit grant" +rm -f "$PROFILES_DIR/eve.json" + +# grant без backend — безопасный no-op проверяется ниже вместе с uninstall + +# ── Отсутствие установки: безопасные no-op ── +safe_jq_write --arg t awg 'del(.[$t])' "$BACKENDS_REGISTRY_FILE" || fail "registry del" +rm -f "$AWG_CONF_FILE" +rc=0; _awg_uninstall >/dev/null 2>&1 || rc=$? +[[ "$rc" == "2" ]] || fail "uninstall rc=$rc on not-installed (expected 2)" +pass "awg uninstall is a safe no-op (rc 2) when not installed" + +# 6) статика: диспетчер и меню +grep -Fq ' awg-install)' xrayebator || fail "dispatch awg-install missing" +grep -Fq ' awg-uninstall)' xrayebator || fail "dispatch awg-uninstall missing" +grep -Fq ' awg-status)' xrayebator || fail "dispatch awg-status missing" +grep -Fq ' awg-grant)' xrayebator || fail "dispatch awg-grant missing" +grep -Fq ' awg-conf)' xrayebator || fail "dispatch awg-conf missing" +grep -Fq ' 12) awg_menu ;;' xrayebator || fail "menu dispatch 12 missing" +grep -Fq ' 13) backend_status_menu ;;' xrayebator || fail "menu dispatch 13 missing" +grep -Fq 'awg_menu() {' xrayebator || fail "awg_menu definition missing" +grep -Fq 'awg_conf_menu() {' xrayebator || fail "awg_conf_menu definition missing" +grep -Fq 'backend_status_menu() {' xrayebator || fail "backend_status_menu definition missing" +grep -Fq '_backend_apply_profile_lifecycle "$name" revoked' xrayebator || fail "revoke lifecycle wiring missing" +grep -Fq '"$(basename "$profile_file" .json)" expired' xrayebator || fail "expire lifecycle wiring missing" +pass "CLI dispatch, menu, lifecycle wiring present (12/13)" + +echo "" +pass "amneziawg backend (slice 4): all checks green" diff --git a/validation/test-backend-registry.sh b/validation/test-backend-registry.sh new file mode 100644 index 00000000..3ee0e99a --- /dev/null +++ b/validation/test-backend-registry.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Тест backend-реестра мультипротокольного этапа 1 (2026-10-04). +# Функциональный: source xrayebator в source-режиме (SOURCE-SAFETY GUARD +# пропускает root-check/key-load/dispatch) с BACKENDS_ROOT во временном каталоге. +# Проверяет: ensure/idempotency, set/field, installed, lifecycle no-op, +# status JSON форму, сохранение ранее записанных полей. +set -u +cd "$(dirname "$0")/.." + +fail() { echo "✗ $1"; exit 1; } +pass() { echo "✓ $1"; } + +command -v jq >/dev/null 2>&1 || fail "jq required (CI installs it)" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT +export BACKENDS_ROOT="$TMP_ROOT/xrayebator-backends" +# safe_jq_write -> _lock_acquire открывает LOCK_FILE; в тестовой среде +# /usr/local/etc/xray не существует — уводим lock во временный каталог. +export LOCK_FILE="$TMP_ROOT/xrayebator.lock" + +# shellcheck disable=SC1091 +source ./xrayebator || fail "source ./xrayebator failed" + +[[ "$XRAYEBATOR_SOURCED" == "1" ]] || fail "source-mode guard not detected" +[[ "$BACKENDS_ROOT" == "$TMP_ROOT/xrayebator-backends" ]] || fail "BACKENDS_ROOT override lost" + +# 1) ensure: создаёт корень, каталог и пустой реестр +_backend_ensure_registry || fail "_backend_ensure_registry failed" +[[ -d "$BACKENDS_ROOT" ]] || fail "BACKENDS_ROOT dir not created" +[[ -d "$BACKENDS_DIR" ]] || fail "BACKENDS_DIR not created" +[[ -s "$BACKENDS_REGISTRY_FILE" ]] || fail "registry file missing/empty" +[[ "$(jq -r 'type' "$BACKENDS_REGISTRY_FILE")" == "object" ]] || fail "registry is not a JSON object" + +# 2) по умолчанию бэкенды не установлены +_backend_installed hysteria2 && fail "hysteria2 reported installed on empty registry" +_backend_installed awg && fail "awg reported installed on empty registry" +pass "empty registry: no backends installed" + +# 3) set создаёт объект бэкенда и пишет поле (jq-опции до фильтра) +_backend_set hysteria2 '(.hysteria2.port) = $port' --argjson port 443 \ + || fail "_backend_set hysteria2 port failed" +[[ "$(_backend_field hysteria2 port)" == "443" ]] || fail "field readback port != 443" + +# 4) второй set НЕ затирает предыдущие поля и включает installed +_backend_set hysteria2 '(.hysteria2.installed) = true' \ + || fail "_backend_set hysteria2 installed failed" +_backend_installed hysteria2 || fail "_backend_installed hysteria2 after set" +[[ "$(_backend_field hysteria2 port)" == "443" ]] || fail "port lost after second set" +pass "set accumulates fields, installed flips on" + +# 5) set для отсутствующего типа создаёт объект с нуля +_backend_set awg '(.awg.port) = $port' --argjson port 51820 \ + || fail "_backend_set awg failed" +[[ "$(_backend_field awg port)" == "51820" ]] || fail "awg field readback" +_backend_installed awg && fail "awg installed must stay false (only port set)" +pass "set auto-creates backend object" + +# 6) lifecycle-хук: no-op безопасен для любых событий (обработчиков ещё нет) +_backend_apply_profile_lifecycle "some-profile" created || fail "lifecycle created failed" +_backend_apply_profile_lifecycle "some-profile" expired || fail "lifecycle expired failed" +_backend_apply_profile_lifecycle "some-profile" bogus || fail "lifecycle bogus event failed" +pass "lifecycle hook is a safe no-op without handlers" + +# 6b) boolean false читается из реестра как "false", а не глотается как null +_backend_set hysteria2 '(.hysteria2.sub_body) = false' || fail "set sub_body=false" +[[ "$(_backend_field hysteria2 sub_body)" == "false" ]] \ + || fail "boolean false must read back as 'false' (jq // empty swallows it)" +pass "boolean false fields read back correctly" + +# 7) status JSON: ok=true, форма backends.{type}.{installed,port,state} +status="$(_backend_status_json)" || fail "_backend_status_json failed" +jq -e ' + .ok == true and + .backends.hysteria2.installed == true and + .backends.hysteria2.port == 443 and + .backends.hysteria2.state == "unknown" and + .backends.awg.installed == false and + .backends.awg.port == 51820 and + .backends.awg.state == "unknown" +' <<<"$status" >/dev/null || fail "status JSON shape mismatch: $status" +pass "status JSON shape correct" + +# 8) idempotent ensure повторно (после мутаций) +_backend_ensure_registry || fail "second ensure failed" +[[ "$(_backend_field hysteria2 port)" == "443" ]] || fail "registry mutated by second ensure" +pass "ensure is idempotent" + +# 9) порт-проверка UDP не падает в среде без ss (возвращает 0/1, не креш) +_backend_port_busy_udp 44399 >/dev/null 2>&1 +rc=$? +[[ "$rc" == "0" || "$rc" == "1" ]] || fail "_backend_port_busy_udp rc=$rc (expected 0/1)" +pass "udp busy check safe without listener" + +echo "" +pass "backend registry: all checks green" diff --git a/validation/test-happ-client-routing.sh b/validation/test-happ-client-routing.sh new file mode 100644 index 00000000..beb13f6e --- /dev/null +++ b/validation/test-happ-client-routing.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Тесты HAPP client routing: split-генератор (PR #23), плейсхолдеры (PR #22), +# nginx-буферы в шаблонах подписки. Static + functional (source-mode). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# shellcheck disable=SC1091 +source "$REPO_ROOT/xrayebator" + +echo "1) split-генератор: собирает JSON из bypass-наборов с плейсхолдерами" +generated=$(_happ_client_split_routing_json) || fail "генератор вернул ошибку" +[[ "$generated" == *'"GlobalProxy": "true"'* ]] || fail "GlobalProxy != true" +[[ "$generated" == *'{{GEOIP_URL}}'* && "$generated" == *'{{GEOSITE_URL}}'* && "$generated" == *'{{LAST_UPDATED}}'* ]] \ + || fail "нет плейсхолдеров в сгенерированном профиле" +n_direct=$(jq -r '.DirectSites | length' <<<"$generated") +[[ "$n_direct" -ge 200 ]] || fail "DirectSites подозрительно мало: $n_direct" +jq -e '.DirectSites | index("domain:ozon.ru") and index("domain:sberbank.ru") and index("domain:gosuslugi.ru")' <<<"$generated" >/dev/null \ + || fail "ключевые российские домены не в DirectSites" +jq -e '.DirectIp | index("geoip:ru")' <<<"$generated" >/dev/null || fail "нет geoip:ru" +jq -e '(.DirectSites - .DirectSites | length) == 0' <<<"$generated" >/dev/null || fail "дубликаты в DirectSites" +echo " OK: $n_direct доменов напрямую, плейсхолдеры на месте" + +echo "2) nginx-шаблоны подписки: proxy_buffer_size 32k в location /sub/" +grep -Fq 'proxy_buffer_size 32k' "$REPO_ROOT/xrayebator" || fail "нет proxy_buffer_size 32k" +[[ "$(grep -Fc 'proxy_buffer_size 32k' "$REPO_ROOT/xrayebator")" -ge 2 ]] \ + || fail "буферы применены не во всех шаблонах (нужно >= 2 location /sub/)" +echo " OK: буферы в шаблонах" + +echo "3) subhttp: подстановка плейсхолдеров до валидации" +grep -Fq '{{GEOIP_URL}}' "$REPO_ROOT/xrayebator" || fail "нет подстановки GEOIP_URL" +grep -Fq '{{LAST_UPDATED}}' "$REPO_ROOT/xrayebator" || fail "нет подстановки LAST_UPDATED" +grep -Fq '_happ_routing_last_updated' "$REPO_ROOT/xrayebator" || fail "нет вызова _happ_routing_last_updated" +# Порядок: подстановка раньше валидации ВНУТРИ блока переопределения +ln_subst=$(grep -Fn 'routing_custom=${routing_custom//\{\{GEOIP_URL\}\}' "$REPO_ROOT/xrayebator" | head -1 | cut -d: -f1) +[[ -n "$ln_subst" ]] || fail "не найдена строка подстановки" +ln_val=$(grep -Fn '_happ_validate_routing_json' "$REPO_ROOT/xrayebator" | awk -F: -v s="$ln_subst" '$1 > s {print $1; exit}') +[[ -n "$ln_val" ]] || fail "после подстановки нет валидации" +echo " OK: подстановка (строка $ln_subst) до валидации (строка $ln_val)" + +echo "4) меню: пункт 7 маршрутизации клиента в HAPP-меню" +grep -Fq 'happ_client_routing_menu' "$REPO_ROOT/xrayebator" || fail "нет happ_client_routing_menu" +grep -Fq 'Маршрутизация клиента (split для российских сервисов)' "$REPO_ROOT/xrayebator" || fail "нет пункта 7 в меню" +grep -Fq '7) happ_client_routing_menu ;;' "$REPO_ROOT/xrayebator" || fail "нет case 7" +echo " OK: меню подключено" + +echo "OK: HAPP client routing — все проверки пройдены" diff --git a/validation/test-happ-subscription-static.sh b/validation/test-happ-subscription-static.sh index 1bc315b3..48476a83 100755 --- a/validation/test-happ-subscription-static.sh +++ b/validation/test-happ-subscription-static.sh @@ -37,8 +37,12 @@ grep -Fq 'elif [[ "$path" =~ ^/sub/([a-f0-9]{32})/(geoip|geosite)\.dat$ ]]' "$SU || fail "strict token-protected geo asset route is missing" grep -Fq '[[ -n "$asset" ]] && serve_geo_asset "$asset"' "$SUBHTTP_TMP" \ || fail "authenticated geo asset route does not reach the file handler" -grep -q '_happ_validate_routing_json_file' "$SUBHTTP_TMP" \ +# PR #22: подстановка плейсхолдеров до валидации, валидация — runtime +# `_happ_validate_routing_json` (после подстановки), а не _file-вариант. +grep -q '_happ_validate_routing_json' "$SUBHTTP_TMP" \ || fail "custom HAPP routing JSON must be schema-validated" +grep -q '{{GEOIP_URL}}' "$SUBHTTP_TMP" \ + || fail "custom HAPP routing placeholders are not substituted per request" grep -q '_happ_default_routing_json' "$SUBHTTP_TMP" \ || fail "managed HAPP routing profile is not generated" grep -Fq "routing_b64=\$(printf '%s' \"\$routing_json\" | base64 -w0)" "$SUBHTTP_TMP" \ diff --git a/validation/test-hysteria2-lifecycle.sh b/validation/test-hysteria2-lifecycle.sh new file mode 100644 index 00000000..948cbbf3 --- /dev/null +++ b/validation/test-hysteria2-lifecycle.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Тест backend Hysteria 2 — срезы 2+3 (2026-10-04). +# Функционально проверяются чистые функции (render server.yaml / systemd-unit, +# arch-маппинг, TLS-детект без LE-маркеров), гранты per-profile, события +# жизненного цикла, pure-ссылка и интеграция с реестром/статусом — в +# source-режиме с переопределёнными BACKENDS_ROOT/PROFILES_DIR/LOCK_FILE +# и фейковым systemctl (CI без systemd). Полный install/uninstall с сетью, +# openssl и настоящим systemd покрывается живым спайком на VPS (срез 7). +set -u +cd "$(dirname "$0")/.." + +fail() { echo "✗ $1"; exit 1; } +pass() { echo "✓ $1"; } + +command -v jq >/dev/null 2>&1 || fail "jq required (CI installs it)" +command -v openssl >/dev/null 2>&1 || fail "openssl required" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT +export BACKENDS_ROOT="$TMP_ROOT/xrayebator-backends" +export LOCK_FILE="$TMP_ROOT/xrayebator.lock" +export PROFILES_DIR="$TMP_ROOT/profiles" +mkdir -p "$PROFILES_DIR" + +# Фейковый systemctl: CI/WSL без systemd — is-active должен говорить active, +# чтобы apply_config/regen проходили детерминированно. +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" +cat > "$FAKEBIN/systemctl" <<'EOF' +#!/usr/bin/env bash +# Fake systemctl for tests: everything succeeds. +exit 0 +EOF +chmod 755 "$FAKEBIN/systemctl" +export PATH="$FAKEBIN:$PATH" +export XRAYEBATOR_SERVER_ADDR_OVERRIDE="203.0.113.10" + +# shellcheck disable=SC1091 +source ./xrayebator || fail "source ./xrayebator failed" + +# ── Срез 2: рендеры и детекты ── + +yaml="$TMP_ROOT/server.yaml" +_hysteria2_render_server_yaml "$yaml" 443 "/opt/c.pem" "/opt/k.pem" || fail "render yaml" +grep -q '^listen: :443$' "$yaml" || fail "yaml listen" +grep -q '^ cert: /opt/c.pem$' "$yaml" || fail "yaml cert" +grep -q '^ key: /opt/k.pem$' "$yaml" || fail "yaml key" +grep -q '^ type: userpass$' "$yaml" || fail "yaml auth type" +grep -q '^ userpass: {}$' "$yaml" || fail "yaml userpass empty map" +grep -q '^ type: 404$' "$yaml" || fail "yaml masquerade 404" +pass "server.yaml rendered with expected structure" + +_hysteria2_render_server_yaml "$yaml" 8443 "/c" "/k" '{"alice":"pw1"}' || fail "render yaml users" +grep -q '^ userpass: {"alice":"pw1"}$' "$yaml" || fail "yaml userpass map inline" +pass "server.yaml accepts users map parameter" + +unit="$TMP_ROOT/hysteria-server.service" +_hysteria2_render_unit "$unit" || fail "render unit" +grep -q '^User=hysteria$' "$unit" || fail "unit User" +grep -qF 'ExecStart=/usr/local/bin/hysteria server -c /usr/local/etc/xrayebator/backends/hysteria/server.yaml' "$unit" || fail "unit ExecStart" +grep -q '^AmbientCapabilities=CAP_NET_BIND_SERVICE$' "$unit" || fail "unit AmbientCapabilities" +grep -q '^NoNewPrivileges=true$' "$unit" || fail "unit NoNewPrivileges" +grep -q '^Restart=on-failure$' "$unit" || fail "unit Restart" +pass "systemd unit rendered (dedicated user + CAP_NET_BIND_SERVICE + auto-restart)" + +arch=$(_hysteria2_asset_arch) || fail "asset arch" +case "$arch" in amd64|arm64|arm|386) ;; *) fail "unexpected arch: $arch" ;; esac +pass "asset arch resolves: $arch" + +[[ "$(_hysteria2_tls_mode_detect)" == "selfsigned" ]] || fail "tls detect expected selfsigned" +pass "tls mode defaults to selfsigned without subscription markers" + +# ── Реестр + статус ── + +_backend_set hysteria2 '(.hysteria2.installed) = true' || fail "set installed" +_backend_set hysteria2 '(.hysteria2.version) = $v' --arg v "v2.12.3" || fail "set version" +_backend_set hysteria2 '(.hysteria2.port) = $p' --argjson p 443 || fail "set port" +_backend_set hysteria2 '(.hysteria2.unit) = $u' --arg u "hysteria-server.service" || fail "set unit" +_backend_set hysteria2 '(.hysteria2.tls_mode) = $m' --arg m "selfsigned" || fail "set tls" +_backend_set hysteria2 '(.hysteria2.sni) = $s' --arg s "" || fail "set sni" +_backend_set hysteria2 '(.hysteria2.sub_body) = true' || fail "set sub_body" +status="$(hysteria2_status_command)" || fail "hysteria2_status_command" +jq -e '.ok == true and + .backend.installed == true and + .backend.version == "v2.12.3" and + .backend.tls_mode == "selfsigned" and + .backend.port == 443 and + (.binary == "absent" or .binary == "present")' <<<"$status" >/dev/null \ + || fail "status JSON shape: $status" +pass "hysteria2-status JSON shape correct (registry fields preserved)" + +# ── Срез 3: гранты, события, ссылка ── +# В реальном жизненном цикле каталог бэкенда создаёт _hysteria2_install; +# тест собирает состояние вручную — воспроизводим это. +mkdir -p "$HYSTERIA2_DIR" + +jq -n '{name:"alice", uuid:"u-alice", transport:"tcp", port:443, fingerprint:"firefox", + sni:"www.example.com", sub_token:"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", created:"2026-10-04"}' \ + > "$PROFILES_DIR/alice.json" || fail "fixture alice" +[[ "$(_hysteria2_grants_count)" == "0" ]] || fail "grants count 0 expected" + +_hysteria2_grant_profile alice || fail "grant alice" +pw1=$(jq -r '.backends.hysteria2.password' "$PROFILES_DIR/alice.json") +[[ "$pw1" =~ ^[0-9a-f]{32}$ ]] || fail "grant password format: $pw1" +jq -r '.backends.hysteria2.username' "$PROFILES_DIR/alice.json" | grep -qx "alice" || fail "grant username" +grep -q "alice: $pw1" "$HYSTERIA2_DIR/server.yaml" 2>/dev/null \ + || grep -q "\"alice\":\"$pw1\"" "$HYSTERIA2_DIR/server.yaml" || fail "regen: alice missing in users map" +[[ "$(_hysteria2_grants_count)" == "1" ]] || fail "grants count 1 expected" +pass "grant_profile writes password to profile and regenerates users map" + +link=$(_hysteria2_profile_link_pure "$PROFILES_DIR/alice.json") || fail "link pure" +[[ "$link" == "hysteria2://alice:${pw1}@203.0.113.10:443/?insecure=1#alice" ]] \ + || fail "link shape: $link" +pass "pure link: selfsigned -> insecure=1, no sni param, name fragment" + +# revoked: ротация пароля +_hysteria2_on_profile_event alice revoked || fail "event revoked" +pw2=$(jq -r '.backends.hysteria2.password' "$PROFILES_DIR/alice.json") +[[ "$pw2" =~ ^[0-9a-f]{32}$ && "$pw2" != "$pw1" ]] || fail "revoked did not rotate password" +grep -q "\"alice\":\"$pw2\"" "$HYSTERIA2_DIR/server.yaml" || fail "revoked: users map not updated" +pass "event revoked rotates password and regenerates config" + +# expired: expire_disabled=true выкидывает грант из users-карты +safe_jq_write '.expire_disabled = true' "$PROFILES_DIR/alice.json" || fail "fixture expire" +_hysteria2_on_profile_event alice expired || fail "event expired" +users_line=$(grep '^ userpass: ' "$HYSTERIA2_DIR/server.yaml") +[[ "$users_line" != *"alice"* ]] || fail "expired: alice still in users map: $users_line" +grep -q '_xrayebator_placeholder' <<<"$users_line" \ + || fail "expired: empty map must fall back to placeholder: $users_line" +pass "event expired removes disabled profile grant (placeholder keeps config valid)" + +# restored: возврат гранта +safe_jq_write '.expire_disabled = false' "$PROFILES_DIR/alice.json" || fail "fixture restore" +_hysteria2_on_profile_event alice restored || fail "event restored" +grep -q "\"alice\":\"$pw2\"" "$HYSTERIA2_DIR/server.yaml" || fail "restored: alice missing" +pass "event restored returns grant to users map" + +# deleted: файл профиля удалён ДО события (как в profile_delete_command) — +# реген users-карты обязан пройти без удалённого гранта, иначе осиротевший +# пароль остаётся в server.yaml и удалённый пользователь сохраняет доступ. +jq -n '{name:"cleo", uuid:"u-cleo", transport:"tcp", port:443}' \ + > "$PROFILES_DIR/cleo.json" || fail "fixture cleo" +_hysteria2_grant_profile cleo || fail "grant cleo" +grep -q '"cleo"' "$HYSTERIA2_DIR/server.yaml" || fail "cleo missing after grant" +rm -f "$PROFILES_DIR/cleo.json" || fail "fixture delete cleo" +_hysteria2_on_profile_event cleo deleted || fail "event deleted (file pre-removed)" +grep -q '"cleo"' "$HYSTERIA2_DIR/server.yaml" && fail "deleted: cleo still in users map" +grep -q '"alice"' "$HYSTERIA2_DIR/server.yaml" || fail "deleted: alice collateral damage" +pass "event deleted (file pre-removed) removes grant from users map" + +# created: авто-выдача при создании отключена — только явный grant. +jq -n '{name:"eve", uuid:"u-eve", transport:"tcp", port:443}' \ + > "$PROFILES_DIR/eve.json" || fail "fixture eve" +_hysteria2_on_profile_event eve created || fail "hysteria2 event created" +jq -e '.backends.hysteria2 == null' "$PROFILES_DIR/eve.json" >/dev/null \ + || fail "created must not auto-grant hysteria2 keys" +pass "event created is a no-op — keys only via explicit grant" +rm -f "$PROFILES_DIR/eve.json" + +# grant_all: массовая выдача с одним регеном +jq -n '{name:"bob", uuid:"u-bob", transport:"xhttp", port:8443, fingerprint:"firefox", + sni:"www.example.com", sub_token:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", created:"2026-10-04"}' \ + > "$PROFILES_DIR/bob.json" || fail "fixture bob" +_hysteria2_grant_all_profiles >/dev/null 2>&1 || fail "grant_all" +[[ "$(_hysteria2_grants_count)" == "2" ]] || fail "grants count 2 expected" +grep -q '"bob":' "$HYSTERIA2_DIR/server.yaml" || fail "grant_all: bob missing" +pass "grant_all provisions every profile with a single regen" + +# grant_profile idempotent: повторный вызов не меняет пароль +_hysteria2_grant_profile alice || fail "grant alice twice" +pw3=$(jq -r '.backends.hysteria2.password' "$PROFILES_DIR/alice.json") +[[ "$pw3" == "$pw2" ]] || fail "grant re-issue rotated password unexpectedly" +pass "grant_profile is idempotent" + +# ── Отсутствие установки: безопасные no-op ── + +safe_jq_write --arg t hysteria2 'del(.[$t])' "$BACKENDS_REGISTRY_FILE" || fail "registry del" +rc=0; _hysteria2_uninstall >/dev/null 2>&1 || rc=$? +[[ "$rc" == "2" ]] || fail "uninstall rc=$rc on not-installed (expected 2)" +rc=0; _hysteria2_grant_profile nobody >/dev/null 2>&1 || rc=$? +[[ "$rc" == "0" ]] || fail "grant without backend rc=$rc (expected no-op 0)" +pass "uninstall/grant are safe no-ops without backend" + +# ── Статика: диспетчер, меню, миграция, подписка ── + +grep -Fq ' hysteria2-install)' xrayebator || fail "dispatch hysteria2-install missing" +grep -Fq ' hysteria2-uninstall)' xrayebator || fail "dispatch hysteria2-uninstall missing" +grep -Fq ' hysteria2-status)' xrayebator || fail "dispatch hysteria2-status missing" +grep -Fq ' hysteria2-grant)' xrayebator || fail "dispatch hysteria2-grant missing" +grep -Fq ' 11) hysteria2_menu ;;' xrayebator || fail "menu dispatch 11 missing" +grep -Fq 'hysteria2_menu() {' xrayebator || fail "hysteria2_menu definition missing" +[[ "$(grep -Fc 'run_migration "subhttp_hysteria2_2026"' xrayebator)" == "2" ]] \ + || fail "migration subhttp_hysteria2_2026 must be registered in main_menu AND quickstart" +grep -Fq '_migrate_subhttp_hysteria2_2026() {' xrayebator || fail "migration fn missing" +grep -Fq 'hysteria2_uri=""' xrayebator || fail "subhttp handler hysteria2_uri wiring missing" +grep -Fq '_backend_apply_profile_lifecycle "$name" created' xrayebator || fail "create lifecycle wiring missing" +grep -Fq '_backend_apply_profile_lifecycle "$name" deleted' xrayebator || fail "delete lifecycle wiring missing" +grep -Fq '_backend_apply_profile_lifecycle "$name" revoked' xrayebator || fail "revoke lifecycle wiring missing" +grep -Fq '"$(basename "$profile_file" .json)" expired' xrayebator || fail "expire wiring missing" +grep -Fq '"$(basename "$profile_file" .json)" restored' xrayebator || fail "restore wiring missing" +pass "CLI dispatch, menu, migration parity, subscription and lifecycle wiring present" + +echo "" +pass "hysteria2 backend (slices 2+3): all checks green" diff --git a/validation/test-legacy-profile-port-sync.sh b/validation/test-legacy-profile-port-sync.sh new file mode 100644 index 00000000..0ae9acbe --- /dev/null +++ b/validation/test-legacy-profile-port-sync.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Регрессия: update_all_profiles_on_port / update_all_profiles_port_reference +# роняли jq на легаси-профиле без поля routes ("Invalid path expression with +# result []"), safe_jq_write глушит stderr — профиль молча оставался со старым SNI. +# Из PR #20 (Inhibit0r), адаптировано. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +WORKDIR=$(mktemp -d /tmp/xrayebator-legacy-sync.XXXXXX) +trap 'rm -rf "$WORKDIR"' EXIT + +# shellcheck disable=SC1091 +source "$REPO_ROOT/xrayebator" + +PROFILES_DIR="$WORKDIR/profiles" +mkdir -p "$PROFILES_DIR" + +# Легаси-профиль: один маршрут, .port/.sni в корне, поля routes нет. +cat > "$PROFILES_DIR/legacy.json" <<'JSON' +{"name":"legacy","uuid":"11111111-1111-1111-1111-111111111111","transport":"tcp","port":443,"sni":"old.example"} +JSON + +# Multi-route профиль: маршрут на 443 обновляется, маршрут на 8443 — нет. +cat > "$PROFILES_DIR/multi.json" <<'JSON' +{"name":"multi","uuid":"22222222-2222-2222-2222-222222222222","routes":[{"label":"a","port":443,"sni":"old.example"},{"label":"b","port":8443,"sni":"untouched.example"}]} +JSON + +echo "Проверка синхронизации SNI по порту" +update_all_profiles_on_port 443 "sni" "new.example" >/dev/null || + fail "update_all_profiles_on_port вернул ошибку" + +[[ "$(jq -r '.sni' "$PROFILES_DIR/legacy.json")" == "new.example" ]] || + fail "легаси-профиль без routes не получил новый SNI" +[[ "$(jq -r '.routes[0].sni' "$PROFILES_DIR/multi.json")" == "new.example" ]] || + fail "маршрут на порту 443 не получил новый SNI" +[[ "$(jq -r '.routes[1].sni' "$PROFILES_DIR/multi.json")" == "untouched.example" ]] || + fail "маршрут на другом порту не должен был меняться" + +echo "Проверка переноса порта" +update_all_profiles_port_reference 443 2053 >/dev/null || + fail "update_all_profiles_port_reference вернул ошибку" + +[[ "$(jq -r '.port' "$PROFILES_DIR/legacy.json")" == "2053" ]] || + fail "легаси-профиль без routes не получил новый порт" +[[ "$(jq -r '.routes[0].port' "$PROFILES_DIR/multi.json")" == "2053" ]] || + fail "маршрут на порту 443 не получил новый порт" +[[ "$(jq -r '.routes[1].port' "$PROFILES_DIR/multi.json")" == "8443" ]] || + fail "маршрут на другом порту не должен был меняться" + +echo "OK: легаси-профили без routes синхронизируются" diff --git a/validation/test-main-menu-numbering.sh b/validation/test-main-menu-numbering.sh index 49dc03e5..2bae5c66 100755 --- a/validation/test-main-menu-numbering.sh +++ b/validation/test-main-menu-numbering.sh @@ -15,7 +15,7 @@ menu_block=$(sed -n '/^main_menu() {$/,/^# Меню создания профи mapfile -t displayed_numbers < <( sed -n 's/.*${CYAN} *\([0-9][0-9]*\))${NC}.*/\1/p' <<< "$menu_block" ) -[[ "${displayed_numbers[*]}" == "1 2 3 4 5 6 7 8 9 10" ]] \ +[[ "${displayed_numbers[*]}" == "1 2 3 4 5 6 7 8 9 10 11 12 13" ]] \ || fail "displayed main-menu actions are not consecutive: ${displayed_numbers[*]}" expected_dispatch=( @@ -29,6 +29,9 @@ expected_dispatch=( "8) cascade_mode_menu ;;" "9) install_selfsteal_stub_menu ;;" "10) setup_outbound_server_menu ;;" + "11) hysteria2_menu ;;" + "12) awg_menu ;;" + "13) backend_status_menu ;;" "0) exit 0 ;;" ) diff --git a/validation/test-profile-revoke-expire-cli.sh b/validation/test-profile-revoke-expire-cli.sh index b0ec890d..44c35198 100644 --- a/validation/test-profile-revoke-expire-cli.sh +++ b/validation/test-profile-revoke-expire-cli.sh @@ -119,6 +119,32 @@ jq -e --arg u "$rotated_uuid" ' jq -e '.uuid == "'"$rotated_uuid"'" and .sub_token == "'"$full_token"'"' \ "$PROFILES_DIR/friend.json" >/dev/null || fail "profile JSON not updated with rotated uuid" +# --- 2b. Revoke --full на SINGLE-ROUTE профиле: у него routes отсутствуют — +# прежнее выражение (.routes // []) |= map(...) было невалидным path в jq +# («Invalid path expression with result []») и роняло полный отзыв. +cp "$CONFIG_FILE" "$WORKDIR/config.before-solo.json" +SOLO_UUID="33333333-3333-4333-8333-333333333333" +jq -n --arg uuid "$SOLO_UUID" '{ + name: "solo", uuid: $uuid, transport: "xhttp", port: 9955, + sub_token: "cccccccccccccccccccccccccccccccc" +}' > "$PROFILES_DIR/solo.json" || fail "fixture solo" +jq -n --arg uuid "$SOLO_UUID" '{ + inbounds: [{port: 9955, tag: "inbound-9955", settings: {clients: [{id: $uuid, flow: ""}], decryption: "none"}}], + routing: {rules: []} +}' > "$CONFIG_FILE" || fail "fixture solo config" +out=$(profile_revoke_command --name solo --full) || fail "revoke --full (single-route) failed: $out" +jq -e '.ok == true and .full == true' <<< "$out" >/dev/null || fail "single-route full bad JSON: $out" +solo_uuid=$(jq -r '.uuid' <<< "$out") +[[ "$solo_uuid" =~ ^[0-9a-fA-F-]{36}$ && "$solo_uuid" != "$SOLO_UUID" ]] || + fail "single-route revoke --full did not rotate uuid: $out" +jq -e --arg u "$solo_uuid" '[.inbounds[].settings.clients[].id] | index($u) != null' \ + "$CONFIG_FILE" >/dev/null || fail "single-route full revoke: new uuid missing in config" +jq -e --arg old "$SOLO_UUID" '[.inbounds[].settings.clients[].id] | index($old) == null' \ + "$CONFIG_FILE" >/dev/null || fail "single-route full revoke: old uuid still in config" +# восстанавливаем состояние секции 2 для последующих проверок +rm -f "$PROFILES_DIR/solo.json" +cp "$WORKDIR/config.before-solo.json" "$CONFIG_FILE" + # --- 3. profiles JSON: календарная дата приходит из часового пояса сервера --- # Сервер в UTC-7 называет момент окончания 30 сентября, хотя в UTC и у клиента # в Москве это уже 1 октября. GUI должен показывать серверную календарную дату. diff --git a/validation/test-quickstart-tls-fallback.sh b/validation/test-quickstart-tls-fallback.sh new file mode 100644 index 00000000..859a223e --- /dev/null +++ b/validation/test-quickstart-tls-fallback.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Regression test: graceful-degradation quickstart при блокировке http-01. +# Root cause (живой VPS 150.241.65.101, 2026-10-08): Let's Encrypt validation +# получает "Connection reset by peer" на http://IP/.well-known/acme-challenge/ — +# порт 80 фильтруется UPSTREAM хостера (RST инжектится до нашего стека; ufw +# открыт, nginx отвечает 200 тем, чьи SYN доходят). Раньше quickstart на этом +# падал с ok:false и сервер оставался полусобранным. Теперь: +# 1) certbot fail + нет действующего серта → fallback SUB_TLS_MODE=http_tls; +# 2) vhost становится HTTP-only (ACME-challenge на :80 сохраняется); +# 3) маркеры пишутся в режиме http_tls (URL http://IP:8080/sub/); +# 4) renew-таймер НЕ ставится (в http_tls нет серта — certbot renew падал бы); +# 5) итоговый JSON несёт degraded:true, tls_mode, certbot_reason; +# 6) ip_tls-путь не изменён (degraded отсутствует, таймер ставится). +# Статический уровень (по паттерну test-quickstart-email-and-inspect.sh): блок +# quickstart_command проверяется на структуру fallback-ветки. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +fail() { + echo "✗ FAIL: $*" >&2 + exit 1 +} + +# CRLF-safe извлечение блока quickstart_command +quickstart_block=$(tr -d '\r' < xrayebator | sed -n '/^quickstart_command() {$/,/^happ_setup_command() {$/p') +[[ -n "$quickstart_block" ]] || fail "quickstart_command block not found" + +# 1) Fallback-переменная и обе ветки существуют +grep -q 'local SUB_TLS_MODE="ip_tls"' <<< "$quickstart_block" \ + || fail "SUB_TLS_MODE не инициализирован в ip_tls" +grep -q 'SUB_TLS_MODE="http_tls"' <<< "$quickstart_block" \ + || fail "certbot-fallback не переключает SUB_TLS_MODE в http_tls" +grep -q 'SUB_TLS_MODE="ip_tls"' <<< "$quickstart_block" \ + || fail "успешный certbot не возвращает SUB_TLS_MODE в ip_tls" + +# 2) Self-signed fallback: ключ+серт создаются в /etc/letsencrypt/selfsigned/ +grep -q '/etc/letsencrypt/selfsigned/${server_ip}/privkey.pem' <<< "$quickstart_block" \ + || fail "self-signed fallback: privkey не создаётся" +grep -q '/etc/letsencrypt/selfsigned/${server_ip}/fullchain.pem' <<< "$quickstart_block" \ + || fail "self-signed fallback: fullchain не создаётся" + +# 3) HTTP-only vhost для http_tls (ACME-challenge сохранён для апгрейда) +grep -q 'NGINXHTTPSUB' <<< "$quickstart_block" \ + || fail "http_tls-ветка nginx-конфига отсутствует" +http_vhost=$(sed -n '/cat > "\$NGINX_SITES_AVAILABLE\/xrayebator-sub" << NGINXHTTPSUB/,/^NGINXHTTPSUB$/p' <<< "$quickstart_block") +[[ -n "$http_vhost" ]] || fail "NGINXHTTPSUB heredoc не найден" +grep -q 'location ^~ /.well-known/acme-challenge/' <<< "$http_vhost" \ + || fail "http_tls vhost потерял ACME-challenge location" +if grep -q 'ssl_certificate' <<< "$http_vhost"; then + fail "http_tls vhost не должен слушать ssl" +fi +if grep -q 'proxy_pass' <<< "$http_vhost"; then + fail "http_tls vhost не должен проксировать: handler 8080 открыт напрямую" +fi + +# 4) Маркеры пишутся с переменной режима (не хардкод "ip_tls") +grep -q '_subscription_write_markers "8443" "$server_ip" "$server_ip" "$SUB_TLS_MODE"' <<< "$quickstart_block" \ + || fail "маркеры должны писаться с \$SUB_TLS_MODE" + +# 5) Renew-таймер только в ip_tls +if grep -q 'if \[\[ "$SUB_TLS_MODE" == "ip_tls" \]\]; then' <<< "$quickstart_block"; then + timer_guard=$(grep -A1 'if \[\[ "$SUB_TLS_MODE" == "ip_tls" \]\]; then' <<< "$quickstart_block" | grep '_install_ip_cert_renew_timer' || true) + [[ -n "$timer_guard" ]] || fail "renew-таймер не под guard'ом ip_tls" +else + fail "guard ip_tls для renew-таймера отсутствует" +fi + +# 6) Итоговый JSON несёт degraded-метаданные +grep -q 'degraded:true' <<< "$quickstart_block" \ + || fail "http_tls-итог не содержит degraded:true" +grep -q 'tls_mode:"http_tls"' <<< "$quickstart_block" \ + || fail "http_tls-итог не содержит tls_mode" +grep -q 'certbot_reason' <<< "$quickstart_block" \ + || fail "http_tls-итог не передаёт certbot_reason" +# ip_tls-итог (финальный echo) не должен содержать degraded — degraded живёт +# только в jq-ветке http_tls; финальный echo — предпоследняя строка блока. +ip_tls_result=$(printf '%s' "$quickstart_block" | grep -F 'echo "{\"' | tail -1) +[[ -n "$ip_tls_result" ]] || fail "ip_tls-итоговый echo не найден" +if grep -q 'degraded' <<< "$ip_tls_result"; then + fail "ip_tls-итог не должен содержать degraded" +fi + +echo "✓ quickstart graceful-degradation (http_tls fallback) guards ok" diff --git a/xrayebator b/xrayebator index a951f63c..cedbf1b2 100755 --- a/xrayebator +++ b/xrayebator @@ -18,7 +18,9 @@ NC='\033[0m' # Пути CONFIG_FILE="/usr/local/etc/xray/config.json" -PROFILES_DIR="/usr/local/etc/xray/profiles" +# PROFILES_DIR переопределяем для функциональных тестов (P2-fix паттерн); +# в проде и subhttp (systemd, чистый env) — дефолт. +PROFILES_DIR="${PROFILES_DIR:-/usr/local/etc/xray/profiles}" DATA_DIR="/usr/local/etc/xray/data" UPSTREAMS_DIR="/usr/local/etc/xray/upstreams" XRAY_BACKUPS_DIR="/usr/local/etc/xray/backups" @@ -42,6 +44,12 @@ TCP_CC_AVAILABLE_FILE="/proc/sys/net/ipv4/tcp_available_congestion_control" HOST_TUNING_MIGRATION_MARKER="/usr/local/etc/xray/.host_tcp_tuning_removed_v3" SYSCTL_SCAN_PATHS="/etc/sysctl.conf:/etc/sysctl.d:/usr/lib/sysctl.d:/lib/sysctl.d" LOCK_FILE="${LOCK_FILE:-/usr/local/etc/xray/.xrayebator.lock}" +# Мультипротокольные бэкенды (этап 1): нейтральный корень ВНЕ /usr/local/etc/xray. +# Решение 2026-10-04: hysteria2/awg не притворяются Xray-путями; реестр один — +# backends.json. BACKENDS_ROOT переопределяем для функциональных тестов (P2-fix паттерн). +BACKENDS_ROOT="${BACKENDS_ROOT:-/usr/local/etc/xrayebator}" +BACKENDS_DIR="$BACKENDS_ROOT/backends" +BACKENDS_REGISTRY_FILE="$BACKENDS_ROOT/backends.json" # P2-fix: пути nginx-сайтов переопределяемы для тестов реальной последовательности # (quickstart rollback). В проде — системные каталоги; в функциональном тесте — # временная ФС. Совпадает с существующим паттерном CONFIG_FILE/PROFILES_DIR override. @@ -469,8 +477,21 @@ find_profile_file_by_uuid() { } find_available_random_port() { + # Нижняя граница 30000 — как была: часть мобильных операторов режет + # исходящие TCP на низкие порты, инбаунд там у таких абонентов недостижим. + # Потолок уводим ПОД эфемерный диапазон ядра (ip_local_port_range): порт + # оттуда может быть занят исходящим сокетом — в `ss -l` его не видно, + # проверка проходит, но xray падает на bind() уже после рестарта и профиль + # откатывается. Если ядро отдало ephemeral выше 60000 — окно не сужаем. + # (Идея из PR #19, Inhibit0r.) local min_port=30000 local max_port=60000 + local eph_min + eph_min=$(awk '{print $1}' /proc/sys/net/ipv4/ip_local_port_range 2>/dev/null) + if [[ "$eph_min" =~ ^[0-9]+$ ]] && (( eph_min > min_port + 1000 )) \ + && (( eph_min - 1 < max_port )); then + max_port=$((eph_min - 1)) + fi local attempts=0 local port @@ -496,7 +517,7 @@ find_available_random_port() { ((attempts++)) done - echo -e "${RED}✗ Не удалось подобрать свободный случайный порт 30000-60000${NC}" >&2 + echo -e "${RED}✗ Не удалось подобрать свободный случайный порт ${min_port}-${max_port}${NC}" >&2 return 1 } @@ -983,60 +1004,6 @@ _subscription_public_tls_endpoint_verified() { return 0 } -# Validate the documented HAPP routing-profile schema before broadcasting it to -# every subscriber. Plain `jq -e .` is not enough: the legacy -# {"name":"xrayebator-default","rules":[]} payload is valid JSON, but it is not -# a HAPP routing profile and leaves the client with a broken, locked profile. -_happ_validate_routing_json() { - local port=$1 domain=$2 addr=$3 mode=$4 - printf '%s' "$port" > /usr/local/etc/xray/.subscription_port - printf '%s' "$domain" > /usr/local/etc/xray/.subscription_domain - printf '%s' "$addr" > /usr/local/etc/xray/.subscription_server_addr - printf '%s' "$mode" > /usr/local/etc/xray/.subscription_mode - chown root:root \ - /usr/local/etc/xray/.subscription_port \ - /usr/local/etc/xray/.subscription_domain \ - /usr/local/etc/xray/.subscription_server_addr \ - /usr/local/etc/xray/.subscription_mode 2>/dev/null || true -} - -# Публичный TLS endpoint подписки реально подтверждён (P1-happ-fix)? -# Проверяем, что наш nginx vhost xrayebator-sub слушает 8443 ssl И сертификат для -# сервера выпущен и действует. Используется happ_setup_command вместо фабрикации -# маркеров: если endpoint не подтверждён — happ-setup вернёт ошибку, а не мёртвый URL. -# Args: $1=server_addr (IPv4/hostname) -_subscription_public_tls_endpoint_verified() { - local server_addr="${1:-}" - [[ -z "$server_addr" ]] && return 1 - # 1) Наш nginx vhost с TLS на 8443 существует. - local vhost="/etc/nginx/sites-available/xrayebator-sub" - if [[ ! -f "$vhost" ]] || ! grep -q "listen 8443 ssl" "$vhost" 2>/dev/null; then - return 1 - fi - # 2) Сертификат для адреса выпущен и файлы на месте. - local cert_dir="/etc/letsencrypt/live/${server_addr}" - if [[ ! -f "$cert_dir/fullchain.pem" || ! -f "$cert_dir/privkey.pem" ]]; then - return 1 - fi - # 3) nginx реально слушает 8443 (не просто конфиг). - if command -v ss >/dev/null 2>&1; then - if ! ss -ltn 2>/dev/null | grep -q ':8443'; then - return 1 - fi - fi - # 4) Сертификат не истёк (openssl доступен). - if command -v openssl >/dev/null 2>&1; then - local end - end=$(openssl x509 -enddate -noout -in "$cert_dir/fullchain.pem" 2>/dev/null | sed 's/notAfter=//') - local now_epoch end_epoch - now_epoch=$(date -u +%s) - end_epoch=$(date -u -d "$end" +%s 2>/dev/null || echo 0) - if [[ -z "$end_epoch" || "$end_epoch" -eq 0 || "$end_epoch" -le "$now_epoch" ]]; then - return 1 - fi - fi - return 0 -} # Validate the documented HAPP routing-profile schema before broadcasting it to # every subscriber. Plain `jq -e .` is not enough: the legacy # {"name":"xrayebator-default","rules":[]} payload is valid JSON, but it is not @@ -1803,7 +1770,8 @@ _ufw_remove_owned_port() { rm -f -- "$manifest_tmp" return 1 fi - ufw reload >/dev/null 2>&1 || true + # Без ufw reload: `ufw allow` уже применил правило к живому iptables. + # reload стоит ~2.3с и на время пересборки цепочек дёргает соединения. } _add_owned_firewall_rule() { @@ -1823,71 +1791,1939 @@ _add_owned_firewall_rule() { if ! _ufw_register_port "$port" "$protocol" "$action"; then ufw delete "$action" "${port}/${protocol}" >/dev/null 2>&1 || true return 1 - fi - ufw reload >/dev/null 2>&1 || true + fi + # Без ufw reload — см. комментарий в _apply_ufw_manifest: allow/delete + # применяется к живому iptables сразу, reload только тормозит и рвёт сессии. +} + +# Open port in UFW firewall (idempotent and ownership-safe). +open_firewall_port() { + local port="$1" protocol="${2:-tcp}" + if _ufw_rule_exists "$port" "$protocol" 2>/dev/null; then + echo -e "${CYAN} → Порт ${port}/${protocol} уже открыт; ownership не присваивается${NC}" + return 0 + fi + echo -e "${CYAN} → Открываю порт ${port}/${protocol}...${NC}" + if _add_owned_firewall_rule "$port" "$protocol" allow; then + echo -e "${GREEN} ✓ Порт ${port}/${protocol} открыт${NC}" + return 0 + fi + echo -e "${RED} ✗ Ошибка открытия порта ${port}/${protocol}${NC}" >&2 + return 1 +} + +limit_firewall_port() { + _add_owned_firewall_rule "$1" "${2:-tcp}" limit +} + +# Close port in UFW if not used by other profiles +# Args: $1=port, $2=protocol (default: tcp) +close_firewall_port() { + local port=$1 + local protocol=${2:-tcp} + + # Check if port is still used by any inbound. On a jq/config error, keep the + # firewall rule: accidentally leaving a port open is safer than cutting off a + # live inbound because a damaged config looked empty. + local port_in_use + if ! port_in_use=$(jq -r --argjson port "$port" \ + '.inbounds[] | select(.port == $port) | .tag' \ + "$CONFIG_FILE" 2>/dev/null); then + return 0 + fi + + if [[ -n "$port_in_use" ]]; then + return 0 # Still in use, don't close + fi + + # Never close default ports + local default_ports=(22 80 443 8443 2053 2083 2087 8080 2096 8880 9443) + for dp in "${default_ports[@]}"; do + [[ "$port" == "$dp" ]] && return 0 + done + + # Закрываем только exact rule из ownership manifest. Pre-existing rule на том + # же порту не трогаем даже после удаления последнего Xray inbound. + if _ufw_manifest_has_port "$port" "$protocol"; then + echo -e "${CYAN} → Закрываю порт ${port}/${protocol}...${NC}" + if _ufw_remove_owned_port "$port" "$protocol"; then + echo -e "${GREEN} ✓ Порт закрыт${NC}" + else + echo -e "${YELLOW} ⚠ Не удалось закрыть owned rule ${port}/${protocol}${NC}" >&2 + return 1 + fi + fi + + return 0 +} + +# ═══════════════════════════════════════════════════════════ +# MULTI-PROTOCOL BACKEND REGISTRY (этап 1) +# ═══════════════════════════════════════════════════════════ +# Нейтральный слой для не-Xray бэкендов (Hysteria 2, AmneziaWG 2.0). +# Дизайн: .superpowers/sdd/2026-10-04-multi-protocol-backends-design.md +# Клиентские креденшелы бэкендов живут в профилях (.backends) — профиль +# единственный источник правды; серверные конфиги бэкендов регенерируются +# из профилей. Реестр — плоский JSON: {"": {"installed": bool, ...}}. +# Права: корень 755, реестр 644 root — БЕЗ секретов, его читает subhttp +# (пользователь xray); секреты живут глубже, в 750-каталогах бэкендов. + +# Готовит корень/каталог бэкендов и пустой реестр. Идемпотентен. +_backend_ensure_registry() { + if [[ ! -d "$BACKENDS_ROOT" ]]; then + mkdir -p "$BACKENDS_ROOT" || return 1 + chmod 755 "$BACKENDS_ROOT" 2>/dev/null + fi + if [[ ! -d "$BACKENDS_DIR" ]]; then + mkdir -p "$BACKENDS_DIR" || return 1 + chmod 755 "$BACKENDS_DIR" 2>/dev/null + fi + if [[ ! -s "$BACKENDS_REGISTRY_FILE" ]]; then + if ! jq -n '{}' > "${BACKENDS_REGISTRY_FILE}.tmp.$$" 2>/dev/null; then + rm -f "${BACKENDS_REGISTRY_FILE}.tmp.$$" + return 1 + fi + if [[ ! -s "${BACKENDS_REGISTRY_FILE}.tmp.$$" ]]; then + rm -f "${BACKENDS_REGISTRY_FILE}.tmp.$$" + return 1 + fi + mv "${BACKENDS_REGISTRY_FILE}.tmp.$$" "$BACKENDS_REGISTRY_FILE" || return 1 + chmod 644 "$BACKENDS_REGISTRY_FILE" 2>/dev/null + fi + return 0 +} + +# Установлен ли бэкенд (по реестру). Аргументы: $1 = тип бэкенда. +_backend_installed() { + local type=$1 + [[ -s "$BACKENDS_REGISTRY_FILE" ]] || return 1 + jq -e --arg t "$type" '.[$t].installed == true' "$BACKENDS_REGISTRY_FILE" >/dev/null 2>&1 +} + +# Значение поля реестра бэкенда (пусто, если поля/бэкенда нет). +# ВАЖНО: не jq `// empty` — он проглатывает легитимный boolean false +# (three_enabled/disable_cookies/sub_body), что ломает чтение флагов. +_backend_field() { + local type=$1 field=$2 + [[ -s "$BACKENDS_REGISTRY_FILE" ]] || { echo ""; return 0; } + jq -r --arg t "$type" --arg f "$field" \ + '(.[$t][$f]) | if . == null then "" else tostring end' \ + "$BACKENDS_REGISTRY_FILE" 2>/dev/null +} + +# Мутация реестра через safe_jq_write (валидация + tmp+mv, in-place). +# Usage: _backend_set hysteria2 '(.hysteria2.port) = $port' --argjson port 443 +# Контракт safe_jq_write: jq-опции ДО фильтра, файл — последний аргумент. +_backend_set() { + local type=$1 expr=$2 + shift 2 + _backend_ensure_registry || return 1 + # Гарантируем существование объекта бэкенда (if-форма: совместимость со старым jq). + safe_jq_write --arg t "$type" \ + 'if .[$t] == null then .[$t] = {} else . end' \ + "$BACKENDS_REGISTRY_FILE" || return 1 + if [[ $# -gt 0 ]]; then + safe_jq_write "$@" "$expr" "$BACKENDS_REGISTRY_FILE" + else + safe_jq_write "$expr" "$BACKENDS_REGISTRY_FILE" + fi +} + +# Занят ли UDP-порт системными слушателями. Порты других бэкендов НЕ +# вычитаются здесь — их вызывающий сверяет по реестру. +_backend_port_busy_udp() { + local port=$1 + ss -uln 2>/dev/null | grep -qE "[:.]${port}[[:space:]]" +} + +# active|inactive для systemd-юнита бэкенда; unknown — если юнит не задан. +_backend_unit_state() { + local unit=$1 + [[ -n "$unit" ]] || { echo "unknown"; return 0; } + if systemctl is-active --quiet "$unit" 2>/dev/null; then + echo "active" + else + echo "inactive" + fi +} + +# Машиночитаемый статус реестра бэкендов. ТОЛЬКО JSON на stdout +# (JSON-гигиена CLI как у profile-*). +_backend_status_json() { + if ! _backend_ensure_registry; then + echo '{"ok":false,"error":"registry_init_failed","backends":{}}' + return 1 + fi + local out="{}" + local prev="$out" + local type installed unit state entry + for type in hysteria2 awg; do + prev="$out" + if _backend_installed "$type"; then installed="true"; else installed="false"; fi + unit=$(_backend_field "$type" "unit") + state=$(_backend_unit_state "$unit") + # Прокидываем ВСЮ запись реестра (version/tls_mode/...) — статус не должен + # терять поля, добавленные будущими срезами; state ставится живой проверкой. + entry=$(jq -c --arg t "$type" '.[$t] // {}' "$BACKENDS_REGISTRY_FILE" 2>/dev/null) + [[ -n "$entry" ]] || entry="{}" + out=$(jq -n \ + --argjson base "$prev" \ + --arg t "$type" \ + --argjson entry "$entry" \ + --argjson installed "$installed" \ + --arg state "$state" \ + '$base + {($t): ($entry + {installed: $installed, state: $state})}' 2>/dev/null) + [[ -n "$out" ]] || out="$prev" + done + echo "{\"ok\":true,\"backends\":$out}" +} + +# Единая точка жизненного цикла профиля для всех бэкендов. +# Вызывается ПОСЛЕ успешной Xray-части операции (create/delete/revoke/expire). +# Идемпотентна; для неустановленных бэкендов — no-op. События: +# created | deleted | revoked | expired | restored +# Обработчики добавляются срезами бэкендов: _hysteria2_on_profile_event и т.п. +_backend_apply_profile_lifecycle() { + local name=$1 event=$2 + local type handler + for type in hysteria2 awg; do + _backend_installed "$type" || continue + handler="_${type}_on_profile_event" + declare -F "$handler" >/dev/null 2>&1 || continue + "$handler" "$name" "$event" || \ + echo -e "${YELLOW} ⚠ backend ${type}: событие ${event} для профиля ${name} обработано с ошибкой${NC}" >&2 + done + return 0 +} +# ═══════════════════════════════════════════════════════════ +# END MULTI-PROTOCOL BACKEND REGISTRY +# ═══════════════════════════════════════════════════════════ + +# ═══════════════════════════════════════════════════════════ +# BACKEND: HYSTERIA 2 (быстрый UDP) — срез 2 +# ═══════════════════════════════════════════════════════════ +# Поставка: GitHub releases HyNetworks/hysteria (тег вида app/v2.12.3), +# sha256 из поля digest API-ответа (плюс asset hashes.txt). +# Юнит свой (не get.hy2.sh): пользователь hysteria + CAP_NET_BIND_SERVICE, +# зеркально модели xray.service. TLS адаптивный: LE-серт подписки (копия +# + certbot deploy-hook) или self-signed. masquerade 404 — до живой +# проверки proxy-маскарада в срезе 3. Гранты per-profile — срез 3. + +HYSTERIA2_BIN="/usr/local/bin/hysteria" +HYSTERIA2_DIR="$BACKENDS_DIR/hysteria" +HYSTERIA2_UNIT="hysteria-server.service" +HYSTERIA2_UNIT_FILE="/etc/systemd/system/hysteria-server.service" +HYSTERIA2_USER="hysteria" +HYSTERIA2_SYSCTL_FILE="/etc/sysctl.d/99-xrayebator-hysteria.conf" +HYSTERIA2_CERTBOT_HOOK="/etc/letsencrypt/renewal-hooks/deploy/xrayebator-hysteria.sh" +HYSTERIA2_REPO="HyNetworks/hysteria" + +# Актив Hysteria для архитектуры текущего ядра. +_hysteria2_asset_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + armv7l|armv6l) echo "arm" ;; + i686|i386) echo "386" ;; + *) return 1 ;; + esac +} + +# Последний стабильный релиз. stdout: " ". +# Паттерн _fetch_latest_tag (Xray): GitHub API → fallback 302 redirect. +_hysteria2_fetch_release_info() { + local arch=${1:-amd64} + local api_json tag digest + api_json=$(curl -fsSL --connect-timeout 10 --max-time 20 \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${HYSTERIA2_REPO}/releases/latest" 2>/dev/null) + tag=$(echo "$api_json" | jq -r '.tag_name // ""' 2>/dev/null) + if [[ "$tag" =~ ^app/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + digest=$(echo "$api_json" | jq -r --arg n "hysteria-linux-${arch}" \ + '.assets[]? | select(.name == $n) | .digest // ""' 2>/dev/null) + echo "${tag} ${digest#sha256:}" + return 0 + fi + + local redirect_url + redirect_url=$(curl -fso /dev/null -w '%{url_effective}' \ + --connect-timeout 10 --max-time 15 -L --max-redirs 1 \ + "https://github.com/${HYSTERIA2_REPO}/releases/latest" 2>/dev/null) + tag="${redirect_url##*/}" + if [[ "$tag" =~ ^app/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "${tag} " + return 0 + fi + return 1 +} + +# Рендер серверного конфига (чистая функция — тестируется в CI). +# $5 = users-карта компактным JSON ({"user":"pass"}), по умолчанию пустая. +_hysteria2_render_server_yaml() { + local out_file=$1 port=$2 cert=$3 key=$4 users=${5:-'{}'} + cat > "$out_file" < "$out_file" <<'EOF' +[Unit] +Description=Hysteria 2 server (managed by xrayebator) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=hysteria +ExecStart=/usr/local/bin/hysteria server -c /usr/local/etc/xrayebator/backends/hysteria/server.yaml +WorkingDirectory=/usr/local/etc/xrayebator/backends/hysteria +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +EOF +} + +# TLS-режим: "le " при живом LE-серте подписки, иначе "selfsigned". +_hysteria2_tls_mode_detect() { + local domain="" + [[ -f /usr/local/etc/xray/.subscription_domain ]] && \ + domain=$(tr -d '\r\n' < /usr/local/etc/xray/.subscription_domain 2>/dev/null) + if [[ -n "$domain" && -d "/etc/letsencrypt/live/$domain" ]]; then + echo "le ${domain}" + else + echo "selfsigned" + fi +} + +# Системный пользователь сервиса (зеркало ensure_xray_runtime_user). +_hysteria2_ensure_user() { + id -u "$HYSTERIA2_USER" >/dev/null 2>&1 && return 0 + local nologin + nologin=$(command -v nologin 2>/dev/null || echo "/bin/false") + useradd -r -s "$nologin" -M -d /nonexistent "$HYSTERIA2_USER" 2>/dev/null +} + +# UDP-буферы для QUIC (рекомендация Hysteria; TCP-тюнинг Xrayebator не трогаем). +_hysteria2_apply_sysctl() { + cat > "$HYSTERIA2_SYSCTL_FILE" <<'EOF' +# Managed by xrayebator (Hysteria 2 QUIC buffers) +net.core.rmem_max=16777216 +net.core.wmem_max=16777216 +EOF + chmod 644 "$HYSTERIA2_SYSCTL_FILE" + sysctl -w net.core.rmem_max=16777216 >/dev/null 2>&1 || true + sysctl -w net.core.wmem_max=16777216 >/dev/null 2>&1 || true +} + +# ── Срез 3: гранты per-profile, события жизненного цикла, ссылки ── + +# Счётчик профилей с грантом hysteria2. +_hysteria2_grants_count() { + local n + n=$(jq -s '[.[] | select(.backends.hysteria2.password != null)] | length' \ + "$PROFILES_DIR"/*.json 2>/dev/null) + [[ "$n" =~ ^[0-9]+$ ]] || n=0 + echo "$n" +} + +# Placeholder-пользователь: Hysteria отвергает ПУСТУЮ userpass-карту +# ("empty auth userpass", живой тест 2.26.125.147). Пока нет грантов — +# в карте висит _xrayebator_placeholder со случайным паролем (0600 root). +_hysteria2_ensure_placeholder() { + local pfile="$HYSTERIA2_DIR/.placeholder_pass" + if [[ -s "$pfile" ]]; then + tr -d '\r\n' < "$pfile" + return 0 + fi + openssl rand -hex 16 > "$pfile" 2>/dev/null || return 1 + chmod 600 "$pfile" 2>/dev/null + tr -d '\r\n' < "$pfile" +} + +# Применение нового server.yaml с откатом при неудачном рестарте. +# $1 = путь к готовому yaml; при неудаче на месте остаётся прежний. +_hysteria2_apply_config() { + local new_file=$1 + _backend_installed hysteria2 || return 1 + [[ -s "$new_file" ]] || return 1 + local had_old=false + [[ -f "$HYSTERIA2_DIR/server.yaml" ]] && had_old=true + local backup="$HYSTERIA2_DIR/server.yaml.bak.apply" + if [[ "$had_old" == true ]]; then + cp -f "$HYSTERIA2_DIR/server.yaml" "$backup" || return 1 + fi + cp -f "$new_file" "$HYSTERIA2_DIR/server.yaml" || return 1 + chown root:"$HYSTERIA2_USER" "$HYSTERIA2_DIR/server.yaml" 2>/dev/null + chmod 640 "$HYSTERIA2_DIR/server.yaml" 2>/dev/null + systemctl restart "$HYSTERIA2_UNIT" 2>/dev/null + sleep 1 + if systemctl is-active --quiet "$HYSTERIA2_UNIT" 2>/dev/null; then + rm -f "$backup" + return 0 + fi + echo -e "${YELLOW} ⚠ Hysteria не поднялась с новым конфигом — откатываю...${NC}" >&2 + if [[ "$had_old" == true ]]; then + cp -f "$backup" "$HYSTERIA2_DIR/server.yaml" + chown root:"$HYSTERIA2_USER" "$HYSTERIA2_DIR/server.yaml" 2>/dev/null + chmod 640 "$HYSTERIA2_DIR/server.yaml" 2>/dev/null + systemctl restart "$HYSTERIA2_UNIT" 2>/dev/null + sleep 1 + fi + rm -f "$backup" + return 1 +} + +# Регенерация users-карты из профилей + безопасный рестарт. +# Фильтр expire_disabled: истёкший/отключённый профиль не попадает в auth — +# restored после продления вернёт его автоматически (ключи живут в профиле). +_hysteria2_regen_config() { + _backend_installed hysteria2 || return 0 + local port + port=$(_backend_field hysteria2 port) + [[ "$port" =~ ^[0-9]+$ ]] || return 1 + local users + users=$(jq -sc ' + map(select(.backends.hysteria2.password != null and (.expire_disabled // false) == false)) | + map({key: .backends.hysteria2.username, value: .backends.hysteria2.password}) | + from_entries + ' "$PROFILES_DIR"/*.json 2>/dev/null) || users="{}" + [[ -n "$users" ]] || users="{}" + if [[ "$users" == "{}" ]]; then + # Пустая userpass-карта невалидна для Hysteria — placeholder-пользователь. + local placeholder_pass + placeholder_pass=$(_hysteria2_ensure_placeholder) || return 1 + users=$(jq -cn --arg p "$placeholder_pass" '{_xrayebator_placeholder: $p}') + fi + local tmp="$HYSTERIA2_DIR/.server.yaml.new" + _hysteria2_render_server_yaml "$tmp" "$port" \ + "$HYSTERIA2_DIR/cert.pem" "$HYSTERIA2_DIR/key.pem" "$users" || return 1 + _hysteria2_apply_config "$tmp" + local rc=$? + rm -f "$tmp" + return $rc +} + +# Грант hysteria2 профилю: пароль в .backends.hysteria2 профиля. +# $2 = "no_regen" — не рестартовать (массовая выдача регенит один раз). +_hysteria2_grant_profile() { + local name=$1 + local profile_file="$PROFILES_DIR/$name.json" + _backend_installed hysteria2 || return 0 + [[ -f "$profile_file" ]] || return 1 + local existing + existing=$(jq -r '.backends.hysteria2.password // empty' "$profile_file" 2>/dev/null) + [[ -n "$existing" ]] && return 0 + local password + password=$(openssl rand -hex 16) || return 1 + safe_jq_write --arg u "$name" --arg p "$password" \ + --arg c "$(date +%Y-%m-%d\ %H:%M:%S)" \ + '.backends = ((.backends // {}) + {hysteria2: {username: $u, password: $p, created: $c}})' \ + "$profile_file" || return 1 + [[ "${2:-}" == "no_regen" ]] && return 0 + _hysteria2_regen_config +} + +# Массовая выдача: пишем все профили, реген — один. +_hysteria2_grant_all_profiles() { + _backend_installed hysteria2 || return 0 + local pf name granted=0 failed=0 + for pf in "$PROFILES_DIR"/*.json; do + [[ -f "$pf" ]] || continue + name=$(jq -r '.name // empty' "$pf" 2>/dev/null) + [[ -n "$name" ]] || continue + if _hysteria2_grant_profile "$name" no_regen; then + granted=$((granted+1)) + else + failed=$((failed+1)) + fi + done + _hysteria2_regen_config || return 1 + echo -e "${GREEN} ✓ Гранты Hysteria 2: выдано ${granted}, ошибок ${failed}${NC}" >&2 + return 0 +} + +# Обработчик событий жизненного цикла (вызывается из +# _backend_apply_profile_lifecycle). Все события сводятся к регену users-карты: +# created — грант+реген; revoked — ротация пароля+реген; +# deleted/expired/restored — реген (фильтр expire_disabled + отсутствие файла). +_hysteria2_on_profile_event() { + local name=$1 event=$2 + _backend_installed hysteria2 || return 0 + local profile_file="$PROFILES_DIR/$name.json" + case "$event" in + created) + # Авто-выдача при создании отключена (см. _awg_on_profile_event): + # протокол ключа выбирает оператор, иначе новый профиль получал + # ключи всех установленных бэкендов сразу. + return 0 + ;; + revoked) + [[ -f "$profile_file" ]] || return 0 + jq -e '.backends.hysteria2.password' "$profile_file" >/dev/null 2>&1 || return 0 + local password + password=$(openssl rand -hex 16) || return 1 + safe_jq_write --arg p "$password" \ + '.backends.hysteria2.password = $p' "$profile_file" || return 1 + _hysteria2_regen_config || return 1 + ;; + deleted) + # Аналогично AWG: файл профиля удалён вызывающим ДО lifecycle — + # реген users-карты обязан пройти без удалённого гранта, иначе + # осиротевший пароль остаётся в server.yaml и удалённый + # пользователь сохраняет доступ. + _hysteria2_regen_config || return 1 + ;; + expired|restored) + [[ -f "$profile_file" ]] || return 0 + _hysteria2_regen_config || return 1 + ;; + *) + return 0 + ;; + esac + return 0 +} + +# Pure-построение hysteria2:// URI (без TUI/stdin/записей) — для подписки. +# Вызывается subhttp.sh от пользователя xray: реестр 644, профиль читаем. +_hysteria2_profile_link_pure() { + local profile_file=$1 + [[ -f "$profile_file" ]] || return 1 + _backend_installed hysteria2 || return 1 + local user pass port tls_mode sni host insecure name + user=$(jq -r '.backends.hysteria2.username // empty' "$profile_file" 2>/dev/null) + pass=$(jq -r '.backends.hysteria2.password // empty' "$profile_file" 2>/dev/null) + [[ -n "$user" && -n "$pass" ]] || return 1 + port=$(_backend_field hysteria2 port) + [[ "$port" =~ ^[0-9]+$ ]] || return 1 + tls_mode=$(_backend_field hysteria2 tls_mode) + sni=$(_backend_field hysteria2 sni) + host=$(_connection_server_address 2>/dev/null) || return 1 + [[ -n "$host" ]] || return 1 + case "$tls_mode" in + le*) insecure=0 ;; + *) insecure=1 ;; + esac + local uri="hysteria2://${user}:${pass}@${host}:${port}/?insecure=${insecure}" + [[ -n "$sni" ]] && uri="hysteria2://${user}:${pass}@${host}:${port}/?sni=${sni}&insecure=${insecure}" + name=$(jq -r '.name // empty' "$profile_file" 2>/dev/null) + if [[ -n "$name" ]]; then + # Флаг страны — как у VLESS-маршрутов: в HAPP ключи должны выглядеть + # единообразно (круглый флаг в начале имени). Нет geo-маркера — голое имя. + local geo_country geo_flag + geo_country=$(_geo_country_name) + geo_flag=$(_geo_flag_emoji "$(_geo_country_code)") + if [[ -n "$geo_country" ]]; then + if [[ -n "$geo_flag" ]]; then + name="${geo_flag} ${geo_country} · ${name}" + else + name="${geo_country} · ${name}" + fi + fi + # Фрагмент обязан быть URL-encoded (юникод флага/пробелы) — как в vless://. + name=$(jq -nr --arg n "$name" '$n|@uri') + uri="${uri}#${name}" + fi + echo "$uri" +} + +# CLI: hysteria2-grant --name N. Только JSON на stdout. +hysteria2_grant_command() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + *) shift ;; + esac + done + [[ -n "$name" ]] || { echo '{"ok":false,"error":"--name required"}'; return 1; } + local rc=0 + exec 3>&1; exec 1>&2 + _hysteria2_grant_profile "$name" || rc=$? + exec 1>&3 + if [[ $rc == 0 ]]; then + jq -n --arg n "$name" '{ok: true, name: $n}' + else + jq -n --argjson code "$rc" '{ok: false, code: $code}' + rc=1 + fi + return "$rc" +} + +# Меню: ссылка hysteria2 для профиля с грантом (+QR при наличии qrencode). +hysteria2_link_menu() { + local -a names=() + local pf name + for pf in "$PROFILES_DIR"/*.json; do + [[ -f "$pf" ]] || continue + name=$(jq -r '.backends.hysteria2.username // empty' "$pf" 2>/dev/null) + [[ -n "$name" ]] && names+=("$name") + done + if [[ ${#names[@]} == 0 ]]; then + echo -e "${YELLOW}Нет профилей с грантом Hysteria 2.${NC}" + return 0 + fi + echo "" + echo -e "${CYAN}Профили с грантом:${NC}" + local i=1 + for name in "${names[@]}"; do + echo -e " ${CYAN}${i})${NC} $name" + ((i++)) + done + echo -n -e "${YELLOW}Номер профиля [1-${#names[@]}]: ${NC}" + local choice + read -r choice || return 0 + [[ "$choice" =~ ^[0-9]+$ ]] || return 0 + (( choice >= 1 && choice <= ${#names[@]} )) || return 0 + local selected="${names[$((choice-1))]}" + local link + link=$(_hysteria2_profile_link_pure "$PROFILES_DIR/$selected.json" 2>/dev/null) || { + echo -e "${RED}✗ Не удалось построить ссылку hysteria2${NC}" + return 1 + } + echo "" + echo -e "${GREEN}${link}${NC}" + echo "" + command -v qrencode >/dev/null 2>&1 && qrencode -t ANSIUTF8 "$link" 2>/dev/null + return 0 +} + +# Безопасный рестарт Hysteria: бэкап server.yaml → restart → при неудаче откат. +_hysteria2_safe_restart() { + [[ -f "$HYSTERIA2_DIR/server.yaml" ]] || return 1 + local backup="$HYSTERIA2_DIR/server.yaml.bak.$$" + cp -f "$HYSTERIA2_DIR/server.yaml" "$backup" || return 1 + systemctl restart "$HYSTERIA2_UNIT" 2>/dev/null + sleep 1 + if systemctl is-active --quiet "$HYSTERIA2_UNIT" 2>/dev/null; then + rm -f "$backup" + return 0 + fi + echo -e "${YELLOW} ⚠ Hysteria не поднялась — откатываю server.yaml...${NC}" + cp -f "$backup" "$HYSTERIA2_DIR/server.yaml" + chown root:"$HYSTERIA2_USER" "$HYSTERIA2_DIR/server.yaml" 2>/dev/null + chmod 640 "$HYSTERIA2_DIR/server.yaml" 2>/dev/null + systemctl restart "$HYSTERIA2_UNIT" 2>/dev/null + sleep 1 + rm -f "$backup" + systemctl is-active --quiet "$HYSTERIA2_UNIT" 2>/dev/null && \ + echo -e "${GREEN} ✓ Hysteria восстановлена на прежнем конфиге${NC}" + return 1 +} + +# Установка Hysteria 2. Аргумент: $1 = UDP-порт (по умолчанию 443). +# Человеческий вывод; для CLI JSON — hysteria2_install_command. +# Возвращает: 0=ok, 1=ошибка, 2=уже установлена. +_hysteria2_install() { + local port=${1:-443} + [[ "$port" =~ ^[0-9]+$ ]] && [[ $port -ge 1 ]] && [[ $port -le 65535 ]] || { + echo -e "${RED}✗ Некорректный порт: ${port}${NC}" + return 1 + } + if _backend_installed hysteria2; then + echo -e "${YELLOW}ℹ Hysteria 2 уже установлена (версия $(_backend_field hysteria2 version)).${NC}" + echo -e "${YELLOW} Для переустановки: сначала 'hysteria2-uninstall'.${NC}" + return 2 + fi + if _backend_port_busy_udp "$port"; then + echo -e "${RED}✗ UDP-порт ${port} уже занят другим слушателем.${NC}" + echo -e "${YELLOW} Выберите другой порт.${NC}" + return 1 + fi + + local arch + arch=$(_hysteria2_asset_arch) || { + echo -e "${RED}✗ Архитектура $(uname -m) не поддерживается Hysteria${NC}" + return 1 + } + + echo -e "${CYAN} → Запрашиваю последний релиз Hysteria...${NC}" + local rel_info tag expect_sha + rel_info=$(_hysteria2_fetch_release_info "$arch") || { + echo -e "${RED}✗ Не удалось определить последний релиз Hysteria (GitHub недоступен?)${NC}" + return 1 + } + tag=${rel_info%% *} + expect_sha=${rel_info#* } + local version=${tag#app/} + echo -e "${CYAN} → Релиз: ${version} (hysteria-linux-${arch})${NC}" + + # Скачивание во временный файл + sha256 из API digest. + local url tmp + url="https://github.com/${HYSTERIA2_REPO}/releases/download/${tag}/hysteria-linux-${arch}" + tmp=$(mktemp) || return 1 + echo -e "${CYAN} → Скачиваю бинарник...${NC}" + if ! curl -fL --retry 5 --retry-delay 2 --retry-all-errors \ + --connect-timeout 30 --max-time 600 --http1.1 -o "$tmp" "$url" 2>/dev/null; then + echo -e "${RED}✗ Не удалось скачать ${url}${NC}" + rm -f "$tmp" + return 1 + fi + if [[ -n "$expect_sha" ]]; then + local got_sha + got_sha=$(sha256sum "$tmp" 2>/dev/null | awk '{print $1}') + if [[ "$got_sha" != "$expect_sha" ]]; then + echo -e "${RED}✗ sha256 не совпал (ожидалось ${expect_sha}, получено ${got_sha})${NC}" + rm -f "$tmp" + return 1 + fi + echo -e "${CYAN} ✓ sha256 сверён${NC}" + else + echo -e "${YELLOW} ⚠ sha256 недоступен из API — пропускаю проверку${NC}" + fi + chmod 755 "$tmp" + if ! "$tmp" version >/dev/null 2>&1; then + echo -e "${RED}✗ Бинарник не прошёл self-test (hysteria version)${NC}" + rm -f "$tmp" + return 1 + fi + + echo -e "${CYAN} → Создаю пользователя/каталоги/серт...${NC}" + _hysteria2_ensure_user || { + echo -e "${RED}✗ Не удалось создать системного пользователя ${HYSTERIA2_USER}${NC}" + rm -f "$tmp" + return 1 + } + mkdir -p "$HYSTERIA2_DIR" + + local tls_mode sni domain="" + tls_mode=$(_hysteria2_tls_mode_detect) + [[ "$tls_mode" == le\ * ]] && domain=${tls_mode#le } + if [[ "$tls_mode" == "le "* ]]; then + cp -f "/etc/letsencrypt/live/${domain}/fullchain.pem" "$HYSTERIA2_DIR/cert.pem" && + cp -f "/etc/letsencrypt/live/${domain}/privkey.pem" "$HYSTERIA2_DIR/key.pem" || { + echo -e "${RED}✗ Не удалось скопировать LE-серт из /etc/letsencrypt/live/${domain}${NC}" + rm -f "$tmp" + return 1 + } + sni="$domain" + mkdir -p "$(dirname "$HYSTERIA2_CERTBOT_HOOK")" + cat > "$HYSTERIA2_CERTBOT_HOOK" <<'EOF' +#!/usr/bin/env bash +# Managed by xrayebator: обновление Hysteria 2 после продления certbot. +set -u +domain="${RENEWED_DOMAINS%% *}" +[[ -n "$domain" ]] || exit 0 +dir="/usr/local/etc/xrayebator/backends/hysteria" +live="/etc/letsencrypt/live/$domain" +[[ -d "$dir" && -d "$live" ]] || exit 0 +cp -f "$live/fullchain.pem" "$dir/cert.pem" && cp -f "$live/privkey.pem" "$dir/key.pem" || exit 0 +chown root:hysteria "$dir/cert.pem" "$dir/key.pem" 2>/dev/null +chmod 640 "$dir/cert.pem" "$dir/key.pem" 2>/dev/null +systemctl restart hysteria-server +EOF + chmod 755 "$HYSTERIA2_CERTBOT_HOOK" + echo -e "${CYAN} ✓ TLS: LE-серт подписки (${domain}) + deploy-hook на продление${NC}" + else + openssl ecparam -genkey -name prime256v1 -out "$HYSTERIA2_DIR/key.pem" 2>/dev/null && + openssl req -new -x509 -days 3650 -key "$HYSTERIA2_DIR/key.pem" \ + -out "$HYSTERIA2_DIR/cert.pem" -subj "/CN=hysteria" 2>/dev/null || { + echo -e "${RED}✗ Не удалось сгенерировать self-signed сертификат (openssl?)${NC}" + rm -f "$tmp" + return 1 + } + sni="" + echo -e "${CYAN} ✓ TLS: self-signed (клиенты с insecure=1)${NC}" + fi + + # Стартовый конфиг: Hysteria не принимает пустую userpass-карту — + # рендерим с placeholder-пользователем; реген при грантах его заменит. + local placeholder_pass + placeholder_pass=$(_hysteria2_ensure_placeholder) || { + echo -e "${RED}✗ Не удалось создать placeholder-пароль${NC}" + rm -f "$tmp" + return 1 + } + _hysteria2_render_server_yaml "$HYSTERIA2_DIR/server.yaml" "$port" \ + "$HYSTERIA2_DIR/cert.pem" "$HYSTERIA2_DIR/key.pem" \ + "$(jq -cn --arg p "$placeholder_pass" '{_xrayebator_placeholder: $p}')" + _hysteria2_apply_sysctl + + echo -e "${CYAN} → Устанавливаю бинарник и юнит...${NC}" + [[ -f "$HYSTERIA2_BIN" ]] && cp -f "$HYSTERIA2_BIN" "${HYSTERIA2_BIN}.bak.$(date +%s)" + install -m 755 "$tmp" "$HYSTERIA2_BIN" || { rm -f "$tmp"; return 1; } + rm -f "$tmp" + + _hysteria2_render_unit "$HYSTERIA2_UNIT_FILE" + chown root:"$HYSTERIA2_USER" "$HYSTERIA2_DIR/server.yaml" "$HYSTERIA2_DIR/cert.pem" "$HYSTERIA2_DIR/key.pem" 2>/dev/null + chmod 750 "$HYSTERIA2_DIR" + # Каталог должен быть читаем сервисом: без chown он остаётся root:root + # и CHDIR юнита падает с Permission denied (живой тест 2.26.125.147). + chown root:"$HYSTERIA2_USER" "$HYSTERIA2_DIR" 2>/dev/null + chmod 640 "$HYSTERIA2_DIR/server.yaml" "$HYSTERIA2_DIR/cert.pem" "$HYSTERIA2_DIR/key.pem" 2>/dev/null + + systemctl daemon-reload + if ! systemctl enable --now "$HYSTERIA2_UNIT" 2>/dev/null; then + echo -e "${RED}✗ systemctl enable --now ${HYSTERIA2_UNIT} не удался${NC}" + _hysteria2_install_rollback + return 1 + fi + sleep 1 + if ! systemctl is-active --quiet "$HYSTERIA2_UNIT" 2>/dev/null; then + echo -e "${RED}✗ Hysteria не запустилась — journalctl -u ${HYSTERIA2_UNIT}${NC}" + _hysteria2_install_rollback + return 1 + fi + + open_firewall_port "$port" udp + + # Реестр — последним шагом: записи появляются только у живого бэкенда. + _backend_set hysteria2 '(.hysteria2.installed) = true' && + _backend_set hysteria2 '(.hysteria2.version) = $v' --arg v "$version" && + _backend_set hysteria2 '(.hysteria2.port) = $port' --argjson port "$port" && + _backend_set hysteria2 '(.hysteria2.unit) = $u' --arg u "$HYSTERIA2_UNIT" && + _backend_set hysteria2 '(.hysteria2.tls_mode) = $m' --arg m "$tls_mode" && + _backend_set hysteria2 '(.hysteria2.sni) = $s' --arg s "$sni" && + _backend_set hysteria2 '(.hysteria2.masquerade) = "404"' && + _backend_set hysteria2 '(.hysteria2.obfs) = false' && + _backend_set hysteria2 '(.hysteria2.sub_body) = true' || { + echo -e "${RED}✗ Не удалось записать реестр бэкендов${NC}" + _hysteria2_install_rollback + return 1 + } + + # Гранты существующим профилям (срез 3). Режимы $2: prompt (интерактив), + # grant_all (CLI --grant-all), no_prompt (CLI по умолчанию). + local grant_mode=${2:-prompt} + if [[ "$grant_mode" == "grant_all" ]]; then + _hysteria2_grant_all_profiles + elif [[ "$grant_mode" != "no_prompt" ]]; then + echo "" + echo -n -e "${YELLOW}Выдать доступ Hysteria 2 всем существующим профилям? [y/N]: ${NC}" + local grant_ans + read -r grant_ans || grant_ans=N + [[ "$grant_ans" =~ ^[yYдД]$ ]] && _hysteria2_grant_all_profiles + fi + + echo "" + echo -e "${GREEN}╔═══════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✓ HYSTERIA 2 УСТАНОВЛЕНА ║${NC}" + echo -e "${GREEN}╚═══════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${BLUE}Версия:${NC} $version" + echo -e "${BLUE}Порт:${NC} UDP $port" + echo -e "${BLUE}TLS:${NC} $tls_mode" + echo -e "${BLUE}Грантов выдано:${NC} $(_hysteria2_grants_count)" + return 0 +} + +# Откат артефактов установки при неудаче (registry-записи ещё не писались). +_hysteria2_install_rollback() { + systemctl disable --now "$HYSTERIA2_UNIT" 2>/dev/null + rm -f "$HYSTERIA2_UNIT_FILE" + systemctl daemon-reload 2>/dev/null + rm -rf "$HYSTERIA2_DIR" + rm -f "$HYSTERIA2_SYSCTL_FILE" "$HYSTERIA2_CERTBOT_HOOK" +} + +# Удаление Hysteria 2. Возвращает: 0=ok, 2=не установлена. +_hysteria2_uninstall() { + if ! _backend_installed hysteria2 && [[ ! -f "$HYSTERIA2_UNIT_FILE" ]]; then + echo -e "${YELLOW}ℹ Hysteria 2 не установлена.${NC}" + return 2 + fi + local port + port=$(_backend_field hysteria2 port) + + echo -e "${CYAN} → Останавливаю и удаляю сервис...${NC}" + systemctl disable --now "$HYSTERIA2_UNIT" 2>/dev/null + rm -f "$HYSTERIA2_UNIT_FILE" + systemctl daemon-reload 2>/dev/null + + rm -f "$HYSTERIA2_SYSCTL_FILE" "$HYSTERIA2_CERTBOT_HOOK" + rm -rf "$HYSTERIA2_DIR" + rm -f "$HYSTERIA2_BIN" "$HYSTERIA2_BIN".bak.* 2>/dev/null + + if [[ "$port" =~ ^[0-9]+$ ]]; then + close_firewall_port "$port" udp + fi + + safe_jq_write --arg t hysteria2 'del(.[$t])' "$BACKENDS_REGISTRY_FILE" 2>/dev/null + + echo -e "${GREEN} ✓ Hysteria 2 удалена${NC}" + echo -e "${YELLOW}ℹ Гранты в профилях (.backends.hysteria2) чистятся срезом 3 lifecycle-хуком.${NC}" + return 0 +} + +# CLI: hysteria2-install [--port P]. Только JSON на stdout. +hysteria2_install_command() { + local port=443 grant_mode="no_prompt" + while [[ $# -gt 0 ]]; do + case "$1" in + --port) + [[ $# -ge 2 ]] || { echo '{"ok":false,"error":"--port requires value"}' >&2; return 1; } + port=$2; shift 2 ;; + --grant-all) + grant_mode="grant_all"; shift ;; + *) + echo "{\"ok\":false,\"error\":\"unknown argument: $1\"}" >&2 + return 1 ;; + esac + done + local rc=0 + exec 3>&1; exec 1>&2 + _hysteria2_install "$port" "$grant_mode" || rc=$? + exec 1>&3 + if [[ $rc == 0 ]]; then + jq -n '{ok: true}' + elif [[ $rc == 2 ]]; then + jq -n '{ok: true, already: true}' + rc=0 + else + jq -n --argjson code "$rc" '{ok: false, code: $code}' + fi + return "$rc" +} + +# CLI: hysteria2-uninstall. Только JSON на stdout. +hysteria2_uninstall_command() { + local rc=0 + exec 3>&1; exec 1>&2 + _hysteria2_uninstall || rc=$? + exec 1>&3 + if [[ $rc == 0 ]]; then + jq -n '{ok: true}' + elif [[ $rc == 2 ]]; then + jq -n '{ok: true, already: true}' + rc=0 + else + jq -n --argjson code "$rc" '{ok: false, code: $code}' + fi + return "$rc" +} + +# CLI: hysteria2-status. Только JSON на stdout. +hysteria2_status_command() { + local full + full=$(_backend_status_json) || { echo '{"ok":false,"error":"registry"}'; return 1; } + local bin_state="absent" + [[ -x "$HYSTERIA2_BIN" ]] && bin_state="present" + echo "$full" | jq --arg bin "$bin_state" '{ok: .ok, binary: $bin, backend: .backends.hysteria2}' +} + +# CLI: hysteria2-subbody --on|--off. Kill-switch строк hysteria2:// в телах +# подписки (handler перечитывает реестр на каждый запрос — без рестартов). +# Только JSON на stdout. +hysteria2_subbody_command() { + local mode="" + while [[ $# -gt 0 ]]; do + case "$1" in + --on) mode="on"; shift ;; + --off) mode="off"; shift ;; + *) shift ;; + esac + done + [[ -n "$mode" ]] || { echo '{"ok":false,"error":"--on/--off required"}'; return 1; } + if ! _backend_installed hysteria2; then + echo '{"ok":false,"error":"Hysteria 2 не установлена"}' + return 1 + fi + local v=true + [[ "$mode" == "off" ]] && v=false + _backend_set hysteria2 '(.hysteria2.sub_body) = $v' --argjson v "$v" || { + echo '{"ok":false,"error":"registry write failed"}' + return 1 + } + jq -n --argjson v "$v" '{ok: true, sub_body: $v}' +} + +# CLI: hysteria2-link --name N. Ссылка hysteria2:// для профиля с грантом +# (именование с флагом страны — как у VLESS-маршрутов). Только JSON. +hysteria2_link_command() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + *) shift ;; + esac + done + [[ -n "$name" ]] || { echo '{"ok":false,"error":"--name required"}'; return 1; } + local pf="$PROFILES_DIR/$name.json" + [[ -f "$pf" ]] || { echo '{"ok":false,"error":"Профиль не найден"}'; return 1; } + local link + link=$(_hysteria2_profile_link_pure "$pf" 2>/dev/null) || { + echo '{"ok":false,"error":"Нет гранта Hysteria 2 или бэкенд не установлен"}' + return 1 + } + jq -n --arg n "$name" --arg l "$link" '{ok: true, name: $n, link: $l}' +} + +# Меню 13: сводный статус всех бэкендов. +backend_status_menu() { + while true; do + show_ascii + echo -e "${BLUE}╔═══════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ СТАТУС МУЛЬТИПРОТОКОЛЬНЫХ БЭКЕНДОВ ║${NC}" + echo -e "${BLUE}╚═══════════════════════════════════════════════╝${NC}" + echo "" + _backend_status_json | jq . 2>/dev/null + echo "" + echo -n -e "${YELLOW}Нажмите Enter для возврата...${NC}" + read -r || return 0 + return 0 + done +} + +# Меню 12: AmneziaWG 2.0 (системный VPN). +awg_menu() { + while true; do + show_ascii + echo -e "${BLUE}╔═══════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ AMNEZIAWG 2.0 (СИСТЕМНЫЙ VPN) ║${NC}" + echo -e "${BLUE}╚═══════════════════════════════════════════════╝${NC}" + echo "" + if _backend_installed awg; then + echo -e "${GREEN}Установлен:${NC} интерфейс $AWG_IFACE, порт UDP $(_backend_field awg port), подсеть $(_backend_field awg subnet)" + echo -e "${GREEN}Юнит:${NC} $AWG_UNIT — $(_backend_unit_state "$AWG_UNIT")" + else + echo -e "${YELLOW}Не установлен.${NC}" + fi + echo "" + echo -e "${CYAN} 1)${NC} Установить (kernel DKMS, случайный высокий UDP-порт)" + echo -e "${CYAN} 2)${NC} Статус" + echo -e "${CYAN} 3)${NC} Конфиг клиента (peer)" + local t31 + t31=$(_backend_field awg three_enabled) + [[ "$t31" == "true" || "$t31" == "false" ]] || t31=true + if [[ "$t31" == "true" ]]; then + echo -e "${CYAN} 4)${NC} Режим AWG 3.1: ВКЛЮЧЁН (HeaderProtection+RandomTrailers) — выключить" + else + echo -e "${CYAN} 4)${NC} Режим AWG 3.1: выключен — включить (перевыпустить .conf клиентов)" + fi + echo -e "${CYAN} 5)${NC} Удалить" + echo -e "${RED} 0)${NC} Назад" + echo "" + echo -n -e "${YELLOW}Выберите действие: ${NC}" + local choice + read -r choice || return 0 + case $choice in + 1) + _awg_install + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 2) + awg_status_command | jq . + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 3) + awg_conf_menu + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 4) + if [[ "$t31" == "true" ]]; then + _awg_31_toggle off "" + else + _awg_31_toggle on "" + fi + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 5) + _awg_uninstall + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 0) return 0 ;; + *) echo -e "${RED}Неверный выбор${NC}"; sleep 1 ;; + esac + done +} + +# Меню 11: Hysteria 2 (быстрый UDP). +hysteria2_menu() { + while true; do + show_ascii + echo -e "${BLUE}╔═══════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ HYSTERIA 2 (БЫСТРЫЙ UDP) ║${NC}" + echo -e "${BLUE}╚═══════════════════════════════════════════════╝${NC}" + echo "" + if _backend_installed hysteria2; then + echo -e "${GREEN}Установлена:${NC} $(_backend_field hysteria2 version), порт UDP $(_backend_field hysteria2 port), TLS $(_backend_field hysteria2 tls_mode)" + echo -e "${GREEN}Юнит:${NC} $HYSTERIA2_UNIT — $(_backend_unit_state "$HYSTERIA2_UNIT")" + else + echo -e "${YELLOW}Не установлена.${NC}" + fi + echo "" + echo -e "${CYAN} 1)${NC} Установить (UDP-порт, по умолчанию 443)" + echo -e "${CYAN} 2)${NC} Статус" + echo -e "${CYAN} 3)${NC} Ссылка hysteria2 для профиля" + echo -e "${CYAN} 4)${NC} Удалить" + echo -e "${RED} 0)${NC} Назад" + echo "" + echo -n -e "${YELLOW}Выберите действие: ${NC}" + local choice + read -r choice || return 0 + case $choice in + 1) + local port + echo -n -e "${YELLOW}UDP-порт [443]: ${NC}" + read -r port + port=${port:-443} + _hysteria2_install "$port" + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 2) + hysteria2_status_command | jq . + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 3) + hysteria2_link_menu + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 4) + _hysteria2_uninstall + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r + ;; + 0) return 0 ;; + *) echo -e "${RED}Неверный выбор${NC}"; sleep 1 ;; + esac + done +} +# ═══════════════════════════════════════════════════════════ +# END BACKEND: HYSTERIA 2 +# ═══════════════════════════════════════════════════════════ + +# ═══════════════════════════════════════════════════════════ +# BACKEND: AMNEZIAWG 2.0 (системный VPN) — срезы 4–5 +# ═══════════════════════════════════════════════════════════ +# Решение 2026-10-04: kernel-модуль DKMS, не userspace. Официальный путь — +# PPA amnezia/ppa (README amneziawg-linux-kernel-module); фолбэк — сборка +# из исходников (для ядра ≥5.6 нужен ПОЛНЫЙ linux-source, не только headers). +# Junk-параметры по спецификации AWG 2.0: Jc 1..128, Jmin/dev/null) || return 1 + jq -n --argjson jc "$jc" --argjson jmin "$jmin" --argjson jmax "$jmax" \ + --argjson s1 "$s1" --argjson s2 "$s2" --argjson s3 "$s3" --argjson s4 "$s4" \ + --argjson h1 "$h1" --argjson h2 "$h2" --argjson h3 "$h3" --argjson h4 "$h4" \ + --arg hpk "$hpk" \ + '{Jc:$jc, Jmin:$jmin, Jmax:$jmax, S1:$s1, S2:$s2, S3:$s3, S4:$s4, + H1:$h1, H2:$h2, H3:$h3, H4:$h4, HeaderProtectionKey: $hpk}' +} + +# Догенерация 3.1-полей в старом server-params.json (установки до 3.1-этапа): +# добавляет S3/S4 (≥12, уникальные) и HeaderProtectionKey, если их нет. +_awg_ensure_31_params() { + local junk=$1 + if jq -e 'has("S3") and has("S4") and has("HeaderProtectionKey")' <<<"$junk" >/dev/null 2>&1; then + echo "$junk" + return 0 + fi + local s3=$((RANDOM % 139 + 12)) s4=$((RANDOM % 139 + 12)) hpk + local s1=$(jq -r '.S1 // 15' <<<"$junk") s2=$(jq -r '.S2 // 16' <<<"$junk") + while (( s3 == s1 || s3 == s2 )); do s3=$((RANDOM % 139 + 12)); done + while (( s4 == s1 || s4 == s2 || s4 == s3 )); do s4=$((RANDOM % 139 + 12)); done + hpk=$(openssl rand -base64 32 2>/dev/null) || return 1 + jq -n --argjson j "$junk" --argjson s3 "$s3" --argjson s4 "$s4" --arg hpk "$hpk" \ + '$j + {S3: $s3, S4: $s4, HeaderProtectionKey: $hpk}' +} + +# Свободный высокий UDP-порт под интерфейс (не как у hysteria). +_awg_pick_port() { + local port + for _ in 1 2 3 4 5 6 7 8 9 10; do + port=$((RANDOM % 40000 + 20000)) # 20000..59999 + if ! _backend_port_busy_udp "$port" && [[ "$port" != "$(_backend_field hysteria2 port)" ]]; then + echo "$port" + return 0 + fi + done + return 1 +} + +# Ключевая тройка: stdout "priv pub psk" (base64). +_awg_gen_keys() { + local priv pub psk + priv=$(awg genkey 2>/dev/null) || return 1 + pub=$(printf '%s' "$priv" | awg pubkey 2>/dev/null) || return 1 + psk=$(awg genpsk 2>/dev/null) || return 1 + [[ -n "$priv" && -n "$pub" && -n "$psk" ]] || return 1 + printf '%s %s %s\n' "$priv" "$pub" "$psk" +} + +# Публичный интерфейс default-route (для MASQUERADE). +_awg_public_iface() { + ip route show default 2>/dev/null | awk '{print $5; exit}' +} + +# Рендер серверного интерфейсного конфига (чистая функция — тестируется в CI). +# $1=out $2=port $3=privkey $4=params_json $5=pub_iface; [Peer] добавляет реген. +# $6 = features JSON {hpk:bool, rt:bool, dc:bool} (AWG 3.1); пусто → без 3.x-ключей. +_awg_render_server_conf() { + local out_file=$1 port=$2 privkey=$3 params=$4 iface=$5 features=${6:-'{}'} + local jc jmin jmax s1 s2 s3 s4 h1 h2 h3 h4 hpk + jc=$(jq -r '.Jc' <<<"$params"); jmin=$(jq -r '.Jmin' <<<"$params") + jmax=$(jq -r '.Jmax' <<<"$params"); s1=$(jq -r '.S1' <<<"$params") + s2=$(jq -r '.S2' <<<"$params") + s3=$(jq -r '.S3 // empty' <<<"$params"); s4=$(jq -r '.S4 // empty' <<<"$params") + h1=$(jq -r '.H1' <<<"$params"); h2=$(jq -r '.H2' <<<"$params") + h3=$(jq -r '.H3' <<<"$params"); h4=$(jq -r '.H4' <<<"$params") + hpk=$(jq -r '.HeaderProtectionKey // empty' <<<"$params") + local fe_hpk fe_rt fe_dc + fe_hpk=$(jq -r '.hpk // false' <<<"$features") + fe_rt=$(jq -r '.rt // false' <<<"$features") + fe_dc=$(jq -r '.dc // false' <<<"$features") + { + cat > "$out_file" <> "$out_file" + [[ -n "$s4" ]] && printf 'S4 = %s\n' "$s4" >> "$out_file" + printf 'H1 = %s\nH2 = %s\nH3 = %s\nH4 = %s\n' "$h1" "$h2" "$h3" "$h4" >> "$out_file" + # AWG 3.1: HPK шифрует заголовок (общий секрет), RT — случайный хвост пакетов. + # Оба должны совпадать на сервере и клиенте; включение на живом сервере + # требует перевыпуска всех клиентских .conf. + [[ "$fe_hpk" == true && -n "$hpk" ]] && printf 'HeaderProtectionKey = %s\n' "$hpk" >> "$out_file" + # Формат булевых в парсере awg (kernel UAPI): on/off, НЕ true/false. + [[ "$fe_rt" == true ]] && printf 'RandomTrailers = on\n' >> "$out_file" + [[ "$fe_dc" == true ]] && printf 'DisableCookies = on\n' >> "$out_file" + cat >> "$out_file" </dev/null 2>&1; then + add-apt-repository -y ppa:amnezia/ppa >/dev/null 2>&1 || true + else + echo "deb https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" > /etc/apt/sources.list.d/amneziawg.list + fi + apt-get update -qq >/dev/null 2>&1 || true + apt-get "${APT_LOCK_OPTS[@]}" install -y --no-install-recommends amneziawg >/dev/null 2>&1 + command -v awg >/dev/null 2>&1 +} + +# Ручная сборка (фолбэк, когда PPA недоступен). Требует ПОЛНЫЙ linux-source +# для ядер ≥5.6 — ставим linux-source текущей серии и линкуем в дерево модуля. +_awg_install_packages_manual() { + echo -e "${CYAN} → PPA недоступен — ручная сборка amneziawg из исходников...${NC}" + _apt_wait_lock + apt-get "${APT_LOCK_OPTS[@]}" install -y --no-install-recommends \ + build-essential dkms git "linux-headers-$(uname -r)" linux-source >/dev/null 2>&1 || true + local work + work=$(mktemp -d) || return 1 + git clone --depth 1 "https://github.com/${AWG_REPO_MODULE}.git" "$work/module" >/dev/null 2>&1 || { rm -rf "$work"; return 1; } + git clone --depth 1 "https://github.com/${AWG_REPO_TOOLS}.git" "$work/tools" >/dev/null 2>&1 || { rm -rf "$work"; return 1; } + # Полное дерево исходников ядра для ядер ≥5.6 (README: нужны именно sources). + local ks + ks=$(ls -d /usr/src/linux-source-* 2>/dev/null | head -1) + if [[ -n "$ks" ]]; then + ln -sfn "$(readlink -f "$ks")" "$work/module/src/kernel" + else + echo -e "${YELLOW} ⚠ linux-source не найден — сборка модуля на ядре ≥5.6 может упасть${NC}" + fi + ( cd "$work/module/src" && make >/dev/null 2>&1 && make install >/dev/null 2>&1 ) || { rm -rf "$work"; return 1; } + ( cd "$work/tools/src" && WITH_WGQUICK=yes WITH_SYSTEMDUNITS=yes make >/dev/null 2>&1 && make install >/dev/null 2>&1 ) || { rm -rf "$work"; return 1; } + depmod -a >/dev/null 2>&1 || true + rm -rf "$work" + command -v awg >/dev/null 2>&1 +} + +# Проверка живого модуля: modprobe + lsmod. Без него backend не регистрируем. +_awg_verify_module() { + modprobe amneziawg 2>/dev/null || true + lsmod 2>/dev/null | grep -q '^amneziawg ' +} + +# Установка AWG. Возвращает: 0=ok, 1=ошибка, 2=уже установлена. +_awg_install() { + if _backend_installed awg; then + echo -e "${YELLOW}ℹ AmneziaWG уже установлен.${NC}" + echo -e "${YELLOW} Для переустановки: сначала 'awg-uninstall'.${NC}" + return 2 + fi + command -v iptables >/dev/null 2>&1 || { + echo -e "${RED}✗ iptables не найден (нужен для MASQUERADE)${NC}" + return 1 + } + + # 1) Пакеты: PPA → ручная сборка. Оба шага честно логируются. + if ! _awg_install_packages; then + if ! _awg_install_packages_manual; then + echo -e "${RED}✗ Не удалось установить amneziawg (PPA и ручная сборка)${NC}" + echo -e "${YELLOW} Проверьте ядро/headers: uname -r; dpkg -l | grep linux-headers${NC}" + return 1 + fi + fi + if ! _awg_verify_module; then + echo -e "${RED}✗ Модуль amneziawg не загрузился (modprobe/lsmod)${NC}" + echo -e "${YELLOW} Ядро может быть новее поддерживаемого — см. троблшутинг README amneziawg.${NC}" + return 1 + fi + + # 2) Параметры сервера + local keys port iface junk + keys=$(_awg_gen_keys) || { echo -e "${RED}✗ awg genkey не сработал${NC}"; return 1; } + port=$(_awg_pick_port) || { echo -e "${RED}✗ Не найден свободный UDP-порт${NC}"; return 1; } + iface=$(_awg_public_iface) + [[ -n "$iface" ]] || { echo -e "${RED}✗ Не определён публичный интерфейс (default route)${NC}"; return 1; } + junk=$(_awg_gen_junk) + + local priv pub psk + read -r priv pub psk <<< "$keys" + + mkdir -p "$AWG_DIR" + chmod 700 "$AWG_DIR" 2>/dev/null + jq -n --arg priv "$priv" --arg pub "$pub" \ + --argjson port "$port" --arg iface "$iface" --argjson junk "$junk" \ + --arg subnet "$AWG_SUBNET" --argjson mtu "$AWG_MTU" \ + '{private_key: $priv, public_key: $pub, port: $port, iface: $iface, + junk: $junk, subnet: $subnet, mtu: $mtu}' > "$AWG_PARAMS_FILE" || { + echo -e "${RED}✗ Не удалось записать server-params.json${NC}" + return 1 + } + chmod 600 "$AWG_PARAMS_FILE" 2>/dev/null + + # 3) Конфиг интерфейса (без peer-ов — их добавляет реген среза 5) + _awg_render_server_conf "$AWG_CONF_FILE" "$port" "$priv" "$junk" "$iface" + chmod 600 "$AWG_CONF_FILE" 2>/dev/null + + # 4) ip_forward (системный VPN обязан маршрутизировать) + cat > "$AWG_SYSCTL_FILE" <<'EOF' +# Managed by xrayebator (AmneziaWG forwarding) +net.ipv4.ip_forward=1 +EOF + chmod 644 "$AWG_SYSCTL_FILE" + sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true + + # 5) Интерфейс + # Юнит awg-quick@awg0 ищет конфиг в /etc/amnezia/amneziawg/ (живой тест + # 2.26.125.147) — линкуем наш файл; источник правды остаётся в backends/awg/. + mkdir -p /etc/amnezia/amneziawg + ln -sfn "$AWG_CONF_FILE" /etc/amnezia/amneziawg/awg0.conf + systemctl daemon-reload 2>/dev/null + if ! systemctl enable --now "$AWG_UNIT" 2>/dev/null; then + echo -e "${RED}✗ systemctl enable --now ${AWG_UNIT} не удался${NC}" + echo -e "${YELLOW} Проверьте: awg-quick strip ${AWG_CONF_FILE}; journalctl -u ${AWG_UNIT}${NC}" + systemctl disable --now "$AWG_UNIT" 2>/dev/null + rm -rf "$AWG_DIR" "$AWG_SYSCTL_FILE" + return 1 + fi + sleep 1 + if ! awg show "$AWG_IFACE" >/dev/null 2>&1; then + echo -e "${RED}✗ Интерфейс ${AWG_IFACE} не поднялся${NC}" + systemctl disable --now "$AWG_UNIT" 2>/dev/null + rm -rf "$AWG_DIR" "$AWG_SYSCTL_FILE" + return 1 + fi + + open_firewall_port "$port" udp + + # 6) Реестр — последним шагом + _backend_set awg '(.awg.installed) = true' && + _backend_set awg '(.awg.version) = $v' --arg v "kernel-dkms" && + _backend_set awg '(.awg.port) = $port' --argjson port "$port" && + _backend_set awg '(.awg.unit) = $u' --arg u "$AWG_UNIT" && + _backend_set awg '(.awg.subnet) = $s' --arg s "$AWG_SUBNET" && + _backend_set awg '(.awg.mtu) = $mtu' --argjson mtu "$AWG_MTU" && + _backend_set awg '(.awg.junk) = $junk' --argjson junk "$junk" && + _backend_set awg '(.awg.iface) = $i' --arg i "$iface" && + _backend_set awg '(.awg.sub_body) = false' && + _backend_set awg '(.awg.three_enabled) = true' && + _backend_set awg '(.awg.disable_cookies) = false' || { + echo -e "${RED}✗ Не удалось записать реестр бэкендов${NC}" + systemctl disable --now "$AWG_UNIT" 2>/dev/null + rm -f /etc/amnezia/amneziawg/awg0.conf + rm -rf "$AWG_DIR" "$AWG_SYSCTL_FILE" + # Полу-запись недопустима: вычищаем объект awg из реестра. + safe_jq_write --arg t awg 'del(.[$t])' "$BACKENDS_REGISTRY_FILE" 2>/dev/null + return 1 + } + + # Peer-гранты существующим профилям (срез 5). Режим $1 (у AWG нет аргумента + # порта, в отличие от _hysteria2_install): prompt/grant_all/no_prompt. + local grant_mode=${1:-prompt} + if [[ "$grant_mode" == "grant_all" ]]; then + _awg_grant_all_profiles + elif [[ "$grant_mode" != "no_prompt" ]]; then + echo "" + echo -n -e "${YELLOW}Выдать peer-доступ AmneziaWG всем существующим профилям? [y/N]: ${NC}" + local grant_ans + read -r grant_ans || grant_ans=N + [[ "$grant_ans" =~ ^[yYдД]$ ]] && _awg_grant_all_profiles + fi + + echo "" + echo -e "${GREEN}╔═══════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✓ AMNEZIAWG 2.0 УСТАНОВЛЕН ║${NC}" + echo -e "${GREEN}╚═══════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${BLUE}Интерфейс:${NC} $AWG_IFACE, порт UDP $port, подсеть $AWG_SUBNET" + echo -e "${BLUE}Junk-параметры:${NC} $(jq -c '.junk' "$AWG_PARAMS_FILE")" + echo -e "${BLUE}Peer-ов выдано:${NC} $(_awg_grants_count)" + return 0 +} + +# Удаление AWG. Возвращает: 0=ok, 2=не установлен. +_awg_uninstall() { + if ! _backend_installed awg && [[ ! -f "$AWG_CONF_FILE" ]]; then + echo -e "${YELLOW}ℹ AmneziaWG не установлен.${NC}" + return 2 + fi + local port + port=$(_backend_field awg port) + + echo -e "${CYAN} → Гашу интерфейс и убираю артефакты...${NC}" + systemctl disable --now "$AWG_UNIT" 2>/dev/null + ip link del "$AWG_IFACE" 2>/dev/null || true + rm -f /etc/amnezia/amneziawg/awg0.conf 2>/dev/null + rm -rf "$AWG_DIR" + rm -f "$AWG_SYSCTL_FILE" + + if [[ "$port" =~ ^[0-9]+$ ]]; then + close_firewall_port "$port" udp + fi + + safe_jq_write --arg t awg 'del(.[$t])' "$BACKENDS_REGISTRY_FILE" 2>/dev/null + + echo -e "${GREEN} ✓ AmneziaWG удалён (модуль/пакеты оставлены в системе)${NC}" + return 0 +} + +# Счётчик профилей с peer-грантом AWG. +_awg_grants_count() { + local n + n=$(jq -s '[.[] | select(.backends.awg.client_private_key != null)] | length' \ + "$PROFILES_DIR"/*.json 2>/dev/null) + [[ "$n" =~ ^[0-9]+$ ]] || n=0 + echo "$n" +} + +# CLI: awg-install / awg-uninstall / awg-status. Только JSON на stdout. +awg_install_command() { + local grant_mode="no_prompt" + while [[ $# -gt 0 ]]; do + case "$1" in + --grant-all) grant_mode="grant_all"; shift ;; + *) shift ;; + esac + done + local rc=0 + exec 3>&1; exec 1>&2 + _awg_install "$grant_mode" || rc=$? + exec 1>&3 + if [[ $rc == 0 ]]; then + jq -n '{ok: true}' + elif [[ $rc == 2 ]]; then + jq -n '{ok: true, already: true}' + rc=0 + else + jq -n --argjson code "$rc" '{ok: false, code: $code}' + fi + return "$rc" +} + +awg_uninstall_command() { + local rc=0 + exec 3>&1; exec 1>&2 + _awg_uninstall || rc=$? + exec 1>&3 + if [[ $rc == 0 ]]; then + jq -n '{ok: true}' + elif [[ $rc == 2 ]]; then + jq -n '{ok: true, already: true}' + rc=0 + else + jq -n --argjson code "$rc" '{ok: false, code: $code}' + fi + return "$rc" +} + +awg_status_command() { + local full + full=$(_backend_status_json) || { echo '{"ok":false,"error":"registry"}'; return 1; } + echo "$full" | jq '{ok: .ok, backend: .backends.awg}' +} + +# Переключение режима AWG 3.1 (HeaderProtectionKey + RandomTrailers). +# Смена режима меняет [Interface]-параметры — все выданные клиентские .conf +# должны быть перекачаны заново (об этом оператору явно напоминает вывод). +# $1 = on|off|"" ("" — не менять), $2 = on|off|"" для DisableCookies. +_awg_31_toggle() { + local mode=$1 dc=$2 + _backend_installed awg || { echo -e "${RED}✗ AmneziaWG не установлен${NC}"; return 1; } + [[ -s "$AWG_PARAMS_FILE" ]] || return 1 + # Догенерация 3.1-полей в params (установки, сделанные до 3.1-этапа). + local junk + junk=$(jq -c '.junk // {}' "$AWG_PARAMS_FILE") + junk=$(_awg_ensure_31_params "$junk") || { + echo -e "${RED}✗ Не удалось дополнить 3.1-параметры (openssl?)${NC}" + return 1 + } + local cur dc_cur new + cur=$(_backend_field awg three_enabled) + [[ "$cur" == "true" || "$cur" == "false" ]] || cur=true + dc_cur=$(_backend_field awg disable_cookies) + [[ "$dc_cur" == "true" ]] || dc_cur=false + new="$cur" + case "$mode" in on) new=true ;; off) new=false ;; esac + case "$dc" in on) dc_cur=true ;; off) dc_cur=false ;; esac + jq -c '.junk = $j' --argjson j "$junk" "$AWG_PARAMS_FILE" > "${AWG_PARAMS_FILE}.tmp" \ + && mv "${AWG_PARAMS_FILE}.tmp" "$AWG_PARAMS_FILE" || return 1 + chmod 600 "$AWG_PARAMS_FILE" 2>/dev/null + _backend_set awg '(.awg.three_enabled) = $e' --argjson e "$new" \ + && _backend_set awg '(.awg.disable_cookies) = $d' --argjson d "$dc_cur" || return 1 + _awg_regen_config || return 1 + if [[ "$new" == true ]]; then + echo -e "${GREEN} ✓ AWG 3.1 включён (HeaderProtectionKey + RandomTrailers${dc_cur:+, DisableCookies})${NC}" + else + echo -e "${GREEN} ✓ AWG 3.1 выключен (конфиги в формате 2.0)${NC}" + fi + echo -e "${YELLOW} ⚠ Все выданные клиентские .conf нужно перекачать: [Interface]-параметры изменились.${NC}" + echo -e "${YELLOW} Клиентам нужен AmneziaVPN ≥ 5.0.1.5 — старые не импортируют новые ключи.${NC}" + return 0 +} + +# CLI: awg-31 --on|--off [--disable-cookies|--no-disable-cookies]. Только JSON на stdout. +awg_31_command() { + local mode="" dc="" + while [[ $# -gt 0 ]]; do + case "$1" in + --on) mode="on"; shift ;; + --off) mode="off"; shift ;; + --disable-cookies) dc="on"; shift ;; + --no-disable-cookies) dc="off"; shift ;; + *) shift ;; + esac + done + [[ -n "$mode" || -n "$dc" ]] || { + echo '{"ok":false,"error":"--on / --off / --disable-cookies required"}' + return 1 + } + local rc=0 + exec 3>&1; exec 1>&2 + _awg_31_toggle "$mode" "$dc" || rc=$? + exec 1>&3 + if [[ $rc == 0 ]]; then + local en dcv + en=$(_backend_field awg three_enabled); [[ "$en" == true ]] || en=false + dcv=$(_backend_field awg disable_cookies); [[ "$dcv" == true ]] || dcv=false + jq -n --argjson en "$en" --argjson dc "$dcv" \ + '{ok: true, three_enabled: $en, disable_cookies: $dc}' + else + jq -n --argjson code "$rc" '{ok: false, code: $code}' + rc=1 + fi + return "$rc" +} + +# ── Срез 5: peers per-profile, клиентский .conf, события жизненного цикла ── +# Модель та же, что у hysteria: профиль — источник правды +# (.backends.awg {client_private_key, client_public_key, preshared_key, +# address}), awg0.conf регенерируется из профилей (фильтр expire_disabled) +# и применяется рестартом интерфейса с откатом. Рестарт = короткий обрыв +# туннеля всех peer-ов; гранты/отзывы — редкие события, для этапа 1 приемлемо. + +# Первый свободный адрес подсети (.2..254) с учётом выданных в профилях. +_awg_next_address() { + local used i + used=$(jq -s '[.[] | .backends.awg.address // empty] | map(select(. != ""))' \ + "$PROFILES_DIR"/*.json 2>/dev/null) || used="[]" + for i in $(seq 2 254); do + if ! jq -e --arg a "${AWG_SUBNET_PREFIX}.${i}" 'index($a)' <<<"$used" >/dev/null 2>&1; then + echo "${AWG_SUBNET_PREFIX}.${i}" + return 0 + fi + done + return 1 } -# Open port in UFW firewall (idempotent and ownership-safe). -open_firewall_port() { - local port="$1" protocol="${2:-tcp}" - if _ufw_rule_exists "$port" "$protocol" 2>/dev/null; then - echo -e "${CYAN} → Порт ${port}/${protocol} уже открыт; ownership не присваивается${NC}" +# Набор 3.1-фич из реестра: {hpk, rt, dc}. По умолчанию (нет записей) — включены. +_awg_features_json() { + local en dc + en=$(_backend_field awg three_enabled) + dc=$(_backend_field awg disable_cookies) + [[ "$en" == "true" || "$en" == "false" ]] || en=true + [[ "$dc" == "true" ]] || dc=false + jq -cn --argjson e "$en" --argjson d "$dc" '{hpk: $e, rt: $e, dc: $d}' +} + +# Применение нового awg0.conf: рестарт интерфейса с откатом при неудаче. +_awg_apply_config() { + local new_file=$1 + _backend_installed awg || return 1 + [[ -s "$new_file" ]] || return 1 + local had_old=false + [[ -f "$AWG_CONF_FILE" ]] && had_old=true + local backup="$AWG_CONF_FILE.bak.apply" + if [[ "$had_old" == true ]]; then + cp -f "$AWG_CONF_FILE" "$backup" || return 1 + fi + cp -f "$new_file" "$AWG_CONF_FILE" || return 1 + chmod 600 "$AWG_CONF_FILE" 2>/dev/null + systemctl restart "$AWG_UNIT" 2>/dev/null + sleep 1 + if awg show "$AWG_IFACE" >/dev/null 2>&1; then + rm -f "$backup" return 0 fi - echo -e "${CYAN} → Открываю порт ${port}/${protocol}...${NC}" - if _add_owned_firewall_rule "$port" "$protocol" allow; then - echo -e "${GREEN} ✓ Порт ${port}/${protocol} открыт${NC}" - return 0 + echo -e "${YELLOW} ⚠ Интерфейс ${AWG_IFACE} не поднялся с новым конфигом — откатываю...${NC}" >&2 + if [[ "$had_old" == true ]]; then + cp -f "$backup" "$AWG_CONF_FILE" + chmod 600 "$AWG_CONF_FILE" 2>/dev/null + systemctl restart "$AWG_UNIT" 2>/dev/null + sleep 1 fi - echo -e "${RED} ✗ Ошибка открытия порта ${port}/${protocol}${NC}" >&2 + rm -f "$backup" return 1 } -limit_firewall_port() { - _add_owned_firewall_rule "$1" "${2:-tcp}" limit +# Регенерация awg0.conf: [Interface] из server-params + [Peer] из профилей. +# Истёкший/отключённый профиль (expire_disabled) не попадает в peer-ы — +# restored после продления вернёт его автоматически. +_awg_regen_config() { + _backend_installed awg || return 0 + [[ -s "$AWG_PARAMS_FILE" ]] || return 1 + local port iface priv junk + port=$(jq -r '.port // empty' "$AWG_PARAMS_FILE") + iface=$(jq -r '.iface // empty' "$AWG_PARAMS_FILE") + priv=$(jq -r '.private_key // empty' "$AWG_PARAMS_FILE") + junk=$(jq -c '.junk // {}' "$AWG_PARAMS_FILE") + [[ -n "$port" && -n "$iface" && -n "$priv" ]] || return 1 + local tmp="$AWG_DIR/.awg0.conf.new" + _awg_render_server_conf "$tmp" "$port" "$priv" "$junk" "$iface" \ + "$(_awg_features_json)" || return 1 + local addr cpub psk + while IFS=$'\t' read -r addr cpub psk; do + [[ -n "$addr" && -n "$cpub" && -n "$psk" ]] || continue + cat >> "$tmp" </dev/null) + _awg_apply_config "$tmp" + local rc=$? + rm -f "$tmp" + return $rc } -# Close port in UFW if not used by other profiles -# Args: $1=port, $2=protocol (default: tcp) -close_firewall_port() { - local port=$1 - local protocol=${2:-tcp} +# Грант AWG профилю: ключевая тройка + адрес в .backends.awg. +# $2 = "no_regen" — не рестартовать интерфейс (массовая выдача). +_awg_grant_profile() { + local name=$1 + _backend_installed awg || return 0 + local profile_file="$PROFILES_DIR/$name.json" + [[ -f "$profile_file" ]] || return 1 + local existing + existing=$(jq -r '.backends.awg.client_private_key // empty' "$profile_file" 2>/dev/null) + if [[ -n "$existing" ]]; then + [[ "${2:-}" == "no_regen" ]] && return 0 + _awg_regen_config + return $? + fi + local addr keys cpriv cpub psk + addr=$(_awg_next_address) || { echo -e "${RED}✗ Подсеть ${AWG_SUBNET} заполнена${NC}" >&2; return 1; } + keys=$(_awg_gen_keys) || { echo -e "${RED}✗ awg genkey не сработал${NC}" >&2; return 1; } + read -r cpriv cpub psk <<< "$keys" + safe_jq_write --arg a "$addr" --arg cp "$cpriv" --arg cb "$cpub" --arg ps "$psk" \ + --arg c "$(date +%Y-%m-%d\ %H:%M:%S)" \ + '.backends = ((.backends // {}) + {awg: {client_private_key: $cp, client_public_key: $cb, + preshared_key: $ps, address: $a, created: $c}})' \ + "$profile_file" || return 1 + [[ "${2:-}" == "no_regen" ]] && return 0 + _awg_regen_config +} + +# Массовая выдача peer-ов: один реген на всех. +_awg_grant_all_profiles() { + _backend_installed awg || return 0 + local pf name granted=0 failed=0 + for pf in "$PROFILES_DIR"/*.json; do + [[ -f "$pf" ]] || continue + name=$(jq -r '.name // empty' "$pf" 2>/dev/null) + [[ -n "$name" ]] || continue + if _awg_grant_profile "$name" no_regen; then + granted=$((granted+1)) + else + failed=$((failed+1)) + fi + done + _awg_regen_config || return 1 + echo -e "${GREEN} ✓ Peer-ы AmneziaWG: выдано ${granted}, ошибок ${failed}${NC}" >&2 + return 0 +} - # Check if port is still used by any inbound. On a jq/config error, keep the - # firewall rule: accidentally leaving a port open is safer than cutting off a - # live inbound because a damaged config looked empty. - local port_in_use - if ! port_in_use=$(jq -r --argjson port "$port" \ - '.inbounds[] | select(.port == $port) | .tag' \ - "$CONFIG_FILE" 2>/dev/null); then - return 0 - fi +# Обработчик событий жизненного цикла (через _backend_apply_profile_lifecycle). +_awg_on_profile_event() { + local name=$1 event=$2 + _backend_installed awg || return 0 + local profile_file="$PROFILES_DIR/$name.json" + case "$event" in + created) + # Авто-выдача ключей при создании УМЫШЛЕННО отключена: протокол ключа + # выбирает оператор (GUI «Создать профиль» / меню 11-13 / grant CLI). + # Иначе каждый новый профиль получал ключи ВСЕХ установленных бэкендов. + return 0 + ;; + revoked) + # Ротация ключей peer-а: старые клиентские конфиги перестают подключаться. + [[ -f "$profile_file" ]] || return 0 + jq -e '.backends.awg.client_private_key' "$profile_file" >/dev/null 2>&1 || return 0 + local keys cpriv cpub psk + keys=$(_awg_gen_keys) || return 1 + read -r cpriv cpub psk <<< "$keys" + safe_jq_write --arg cp "$cpriv" --arg cb "$cpub" --arg ps "$psk" \ + '.backends.awg = (.backends.awg + {client_private_key: $cp, + client_public_key: $cb, preshared_key: $ps})' "$profile_file" || return 1 + _awg_regen_config || return 1 + ;; + deleted) + # Файл профиля удалён вызывающим ДО lifecycle (rm в + # profile_delete_command) — реген по оставшимся профилям снимает + # peer удалённого: иначе осиротевший peer живёт вечно и удалённый + # пользователь сохраняет доступ. Гварда на файл здесь быть НЕ должно. + _awg_regen_config || return 1 + ;; + expired|restored) + [[ -f "$profile_file" ]] || return 0 + _awg_regen_config || return 1 + ;; + *) + return 0 + ;; + esac + return 0 +} - if [[ -n "$port_in_use" ]]; then - return 0 # Still in use, don't close +# Pure-рендер клиентского .conf (без TUI/stdin/записей). Секреты в выводе — +# вызывающий сам решает, куда их деть (CLI JSON-обёртка / меню / QR). +_awg_client_conf_pure() { + local name=$1 + local profile_file="$PROFILES_DIR/$name.json" + [[ -f "$profile_file" ]] || return 1 + _backend_installed awg || return 1 + [[ -s "$AWG_PARAMS_FILE" ]] || return 1 + local cpriv addr cpub psk spub port mtu junk host features fe_dc + cpriv=$(jq -r '.backends.awg.client_private_key // empty' "$profile_file" 2>/dev/null) + addr=$(jq -r '.backends.awg.address // empty' "$profile_file" 2>/dev/null) + cpub=$(jq -r '.backends.awg.client_public_key // empty' "$profile_file" 2>/dev/null) + psk=$(jq -r '.backends.awg.preshared_key // empty' "$profile_file" 2>/dev/null) + [[ -n "$cpriv" && -n "$addr" && -n "$cpub" && -n "$psk" ]] || return 1 + spub=$(jq -r '.public_key // empty' "$AWG_PARAMS_FILE") + port=$(jq -r '.port // empty' "$AWG_PARAMS_FILE") + mtu=$(jq -r '.mtu // empty' "$AWG_PARAMS_FILE") + junk=$(jq -c '.junk // {}' "$AWG_PARAMS_FILE") + [[ -n "$spub" && "$port" =~ ^[0-9]+$ ]] || return 1 + host=$(_connection_server_address 2>/dev/null) || return 1 + [[ -n "$host" ]] || return 1 + features=$(_awg_features_json) + fe_dc=$(jq -r '.dc // false' <<<"$features") + local fe_hpk=$(jq -r '.hpk // false' <<<"$features") + local fe_rt=$(jq -r '.rt // false' <<<"$features") + local hpk=$(jq -r '.HeaderProtectionKey // empty' <<<"$junk") + { + # Самодокументирующаяся шапка (этап 2, срез D): в AmneziaVPN видно, что + # профиль живёт вне V2Ray/HAPP-мира и how to перекачать конфиг. + cat <&1; exec 1>&2 + _awg_grant_profile "$name" || rc=$? + exec 1>&3 + if [[ $rc == 0 ]]; then + jq -n --arg n "$name" '{ok: true, name: $n}' + else + jq -n --argjson code "$rc" '{ok: false, code: $code}' + rc=1 fi + return "$rc" +} - # Never close default ports - local default_ports=(22 80 443 8443 2053 2083 2087 8080 2096 8880 9443) - for dp in "${default_ports[@]}"; do - [[ "$port" == "$dp" ]] && return 0 +awg_conf_command() { + local name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + *) shift ;; + esac done + [[ -n "$name" ]] || { echo '{"ok":false,"error":"--name required"}'; return 1; } + local conf + conf=$(_awg_client_conf_pure "$name" 2>/dev/null) || { + echo "{\"ok\":false,\"error\":\"Нет peer-гранта AWG для профиля $name\"}" + return 1 + } + jq -n --arg n "$name" --arg c "$conf" '{ok: true, name: $n, conf: $c}' +} - # Закрываем только exact rule из ownership manifest. Pre-existing rule на том - # же порту не трогаем даже после удаления последнего Xray inbound. - if _ufw_manifest_has_port "$port" "$protocol"; then - echo -e "${CYAN} → Закрываю порт ${port}/${protocol}...${NC}" - if _ufw_remove_owned_port "$port" "$protocol"; then - echo -e "${GREEN} ✓ Порт закрыт${NC}" - else - echo -e "${YELLOW} ⚠ Не удалось закрыть owned rule ${port}/${protocol}${NC}" >&2 - return 1 - fi +# Меню: клиентский .conf для профиля с peer-грантом (+QR при наличии qrencode). +awg_conf_menu() { + local -a names=() + local pf name + for pf in "$PROFILES_DIR"/*.json; do + [[ -f "$pf" ]] || continue + name=$(jq -r '.backends.awg.address // empty' "$pf" 2>/dev/null) + [[ -n "$name" ]] && names+=("$(jq -r '.name' "$pf")") + done + if [[ ${#names[@]} == 0 ]]; then + echo -e "${YELLOW}Нет профилей с peer-грантом AmneziaWG.${NC}" + return 0 + fi + echo "" + echo -e "${CYAN}Профили с peer-грантом:${NC}" + local i=1 + for name in "${names[@]}"; do + echo -e " ${CYAN}${i})${NC} $name" + ((i++)) + done + echo -n -e "${YELLOW}Номер профиля [1-${#names[@]}]: ${NC}" + local choice + read -r choice || return 0 + [[ "$choice" =~ ^[0-9]+$ ]] || return 0 + (( choice >= 1 && choice <= ${#names[@]} )) || return 0 + local selected="${names[$((choice-1))]}" + local conf + conf=$(_awg_client_conf_pure "$selected" 2>/dev/null) || { + echo -e "${RED}✗ Не удалось собрать клиентский конфиг${NC}" + return 1 + } + echo "" + echo -e "${GREEN}${conf}${NC}" + echo "" + echo -n -e "${YELLOW}Показать QR? [y/N]: ${NC}" + local qr_ans + read -r qr_ans || qr_ans=N + if [[ "$qr_ans" =~ ^[yYдД]$ ]] && command -v qrencode >/dev/null 2>&1; then + qrencode -t ANSIUTF8 "$conf" 2>/dev/null fi - return 0 } +# ═══════════════════════════════════════════════════════════ +# END BACKEND: AMNEZIAWG +# ═══════════════════════════════════════════════════════════ + # Check whether a profile has route array profile_has_routes() { local profile_file=$1 @@ -3320,12 +5156,16 @@ _migrate_sni_list_2026() { known_defaults["$domain"]=1 done + # Строки обязаны совпадать с shipped sni_list.txt байт-в-байт: иначе grep -Fxq + # ниже считает список устаревшим и гоняет переписывание файла с рестартом Xray + # на пустом месте. Приоритеты — по замеру 03.09.2026 (MegaFon) с оговоркой: + # e-commerce/банки понижены до 2, но не выброшены (на других операторах живут). local -a to_add_p1=( - "online.uralsib.ru|ru_whitelist|1" - "online.vtb.ru|ru_whitelist|1" - "www.cdek.ru|ru_whitelist|1" - "www.pochta.ru|ru_whitelist|1" - "www.avito.ru|ru_whitelist|1" + "online.uralsib.ru|ru_whitelist|2" + "online.vtb.ru|ru_whitelist|2" + "www.cdek.ru|ru_whitelist|2" + "www.pochta.ru|ru_whitelist|2" + "www.avito.ru|ru_whitelist|2" ) local -a to_add_p3=( "github.com|foreign|3" @@ -4437,7 +6277,10 @@ _default_sni_for_transport() { *) local from_list from_list=$(grep -v '^#' "$SNI_LIST" 2>/dev/null | grep -v '^[[:space:]]*$' | head -n 1 | cut -d'|' -f1) - [[ -z "$from_list" ]] && from_list="www.google.com" + # Дефолт 2026-10-06: www.cloudflare.com — самый стабильный (глобальный + # anycast, TLS1.3, не режется ТСПУ). Список обычно перекрывает фолбэк: + # берётся первая строка sni_list.txt, она держится cloudflare-first. + [[ -z "$from_list" ]] && from_list="www.cloudflare.com" echo "$from_list" ;; esac @@ -5513,6 +7356,14 @@ serve_geo_asset() { # Построить все vless:// маршруты профиля через pure-функции из xrayebator. vless_urls=$(_generate_vless_urls_for_profile "$profile_file" 2>/dev/null) || emit_410_stale +# Hysteria 2 (multi-protocol slice 3): одна ссылка на профиль — если бэкенд +# установлен, флаг sub_body включён и у профиля есть грант. Реестр читается +# на каждый запрос: флаг-выключатель работает без рестарта сервиса. +hysteria2_uri="" +if _backend_installed hysteria2 && [[ "$(_backend_field hysteria2 sub_body)" == "true" ]]; then + hysteria2_uri=$(_hysteria2_profile_link_pure "$profile_file" 2>/dev/null) || hysteria2_uri="" +fi + query_lc=$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]') ua_lc=$(printf '%s' "$user_agent" | tr '[:upper:]' '[:lower:]') universal_sub=false @@ -5603,8 +7454,23 @@ routing_uri="" routing_json="" if [[ "${HAPP_ROUTING_ENABLED,,}" == "true" ]]; then if [[ -s "$HAPP_ROUTING_JSON_FILE" ]]; then - if _happ_validate_routing_json_file "$HAPP_ROUTING_JSON_FILE"; then - routing_json=$(jq -c . "$HAPP_ROUTING_JSON_FILE" 2>/dev/null) || routing_json="" + # Geo database URLs are per-subscriber, so an operator-wide override cannot + # hardcode them without leaking one subscriber's token to everyone else. + # {{GEOIP_URL}}, {{GEOSITE_URL}} and {{LAST_UPDATED}} are filled in per + # request; substitution runs before validation so a leftover placeholder + # fails the https_url check and falls back to the managed default. + # (Идея из PR #22, tony-adamson.) + routing_custom=$(cat "$HAPP_ROUTING_JSON_FILE" 2>/dev/null) || routing_custom="" + if [[ "$routing_custom" == *"{{"* ]]; then + routing_base_url=$(_subscription_base_url 2>/dev/null) + routing_placeholder_lu=$(_happ_routing_last_updated \ + /usr/local/bin/xrayebator "$HAPP_GEOIP_FILE" "$HAPP_GEOSITE_FILE" 2>/dev/null) + routing_custom=${routing_custom//\{\{GEOIP_URL\}\}/${routing_base_url}/sub/${token}/geoip.dat} + routing_custom=${routing_custom//\{\{GEOSITE_URL\}\}/${routing_base_url}/sub/${token}/geosite.dat} + routing_custom=${routing_custom//\{\{LAST_UPDATED\}\}/${routing_placeholder_lu}} + fi + if printf '%s' "$routing_custom" | _happ_validate_routing_json; then + routing_json=$(printf '%s' "$routing_custom" | jq -c . 2>/dev/null) || routing_json="" else printf 'xrayebator-sub: invalid HAPP routing profile ignored: %s\n' \ "$HAPP_ROUTING_JSON_FILE" >&2 @@ -5641,16 +7507,26 @@ if [[ "$universal_sub" == "true" ]]; then # классический v2ray-sub: только proxy URI, без HAPP comments/routing. # Фильтруем PQ xhttp (encryption=mlkem768...): клиенты без ML-KEM не смогут # подключиться, а legacy xhttp (encryption=none) сохраняем. V11. - printf '%s' "$vless_urls" | awk ' - /^vless:\/\// && /&type=xhttp&/ && $0 !~ /encryption=none/ { next } - { print } - ' | base64 -w0 > "$body_tmp" + { + printf '%s' "$vless_urls" | awk ' + /^vless:\/\// && /&type=xhttp&/ && $0 !~ /encryption=none/ { next } + { print } + ' + [[ -n "$hysteria2_uri" ]] && printf '%s\n' "$hysteria2_uri" + } | base64 -w0 > "$body_tmp" else # HAPP standard subscriptions are text-based server lists. Keep body conservative: # only vless:// rows by default; metadata stays in HTTP headers. happ_vless_urls=$(printf '%s' "$vless_urls" | prepare_happ_vless_urls) { - printf '%s' "$happ_vless_urls" + # %s\n обязателен: command substitution съедает trailing newline, и + # hysteria2-строка слипается с последним vless:// (живой тест 2.26). + printf '%s\n' "$happ_vless_urls" + # Мультибэкенд: hysteria2-строка в HAPP-теле (флаг sub_body в реестре — + # выключатель, если парсер HAPP споткнётся; проверка — живой спайк). + if [[ -n "$hysteria2_uri" ]]; then + printf '%s\n' "$hysteria2_uri" + fi if [[ -n "$routing_uri" ]]; then printf '\n%s\n' "$routing_uri" fi @@ -6176,6 +8052,12 @@ server { limit_req zone=xrayebator_sub burst=10 nodelay; add_header Cache-Control "no-store" always; add_header X-Robots-Tag "noindex, nofollow, nosnippet" always; + # HAPP routing-профиль уезжает клиенту в заголовке ответа и легко + # перерастает дефолтные 4k: nginx тогда отвечает 502 и пишет в лог + # "upstream sent too big header". (Идея из PR #23, tony-adamson.) + proxy_buffer_size 32k; + proxy_buffers 8 32k; + proxy_busy_buffers_size 64k; proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host \$host; @@ -6410,6 +8292,12 @@ server { limit_req zone=xrayebator_sub burst=10 nodelay; add_header Cache-Control "no-store" always; add_header X-Robots-Tag "noindex, nofollow, nosnippet" always; + # HAPP routing-профиль уезжает клиенту в заголовке ответа и легко + # перерастает дефолтные 4k: nginx тогда отвечает 502 и пишет в лог + # "upstream sent too big header". (Идея из PR #23, tony-adamson.) + proxy_buffer_size 32k; + proxy_buffers 8 32k; + proxy_busy_buffers_size 64k; proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host \$host; @@ -6935,6 +8823,7 @@ happ_subscription_menu() { echo -e "${CYAN} 4)${NC} Управление существующими подписками (URL / QR / revoke)" echo -e "${CYAN} 5)${NC} Настройки HAPP (.happ_defaults.env)" echo -e "${CYAN} 6)${NC} Настроить announcement (HAPP)" + echo -e "${CYAN} 7)${NC} Маршрутизация клиента (split для российских сервисов)" echo -e "${CYAN} 0)${NC} Назад в главное меню" echo "" echo -n -e "${YELLOW}Выбор: ${NC}" @@ -6947,11 +8836,171 @@ happ_subscription_menu() { 4) manage_subscription_menu ;; 5) happ_settings_menu ;; 6) edit_happ_announce_menu ;; + 7) happ_client_routing_menu ;; + 0) return ;; + esac + done +} + +# Клиентский split-профиль для HAPP. +# +# Серверный bypass (_apply_bypass_rule) меняет только то, куда VPS отправляет +# запрос. Клиент всё равно заворачивает трафик в туннель, поэтому российский +# сайт видит адрес VPS. На ноде без каскада общий outbound и так direct, и +# bypass там ничего не решает. Не пускать домашний трафик в туннель может +# только профиль на стороне клиента — его и генерирует эта функция, переиспользуя +# те же группы доменов, что и серверный bypass. (Идея из PR #23, tony-adamson.) +_happ_client_split_routing_json() { + local gid gname doms_csv dom + local -a direct_sites=() + + while IFS='|' read -r gid gname doms_csv; do + [[ -z "$gid" ]] && continue + while IFS= read -r dom; do + [[ -n "$dom" ]] && direct_sites+=("domain:${dom}") + done < <(tr ',' '\n' <<< "$doms_csv") + done < <(_bypass_bundle_groups) + + [[ ${#direct_sites[@]} -gt 0 ]] || return 1 + + printf '%s\n' "${direct_sites[@]}" | jq -R . | jq -s ' + { + Name: "xrayebator-split", + GlobalProxy: "true", + RemoteDNSType: "DoH", + RemoteDNSDomain: "https://cloudflare-dns.com/dns-query", + RemoteDNSIP: "1.1.1.1", + DomesticDNSType: "DoH", + DomesticDNSDomain: "https://cloudflare-dns.com/dns-query", + DomesticDNSIP: "1.1.1.1", + Geoipurl: "{{GEOIP_URL}}", + Geositeurl: "{{GEOSITE_URL}}", + LastUpdated: "{{LAST_UPDATED}}", + DnsHosts: { "cloudflare-dns.com": "1.1.1.1" }, + DirectSites: (. | unique), + DirectIp: [ + "geoip:ru", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "224.0.0.0/4", + "255.255.255.255" + ], + ProxySites: [], + ProxyIp: [], + BlockSites: [], + BlockIp: [], + DomainStrategy: "IPIfNonMatch", + FakeDNS: "false" + }' +} + +happ_client_routing_menu() { + while true; do + show_ascii + echo -e "${BLUE}═══════════════════════════════════════════════${NC}" + echo -e "${BLUE} МАРШРУТИЗАЦИЯ КЛИЕНТА (HAPP) ${NC}" + echo -e "${BLUE}═══════════════════════════════════════════════${NC}" + echo "" + echo -e "${CYAN}По умолчанию HAPP заворачивает в туннель всё, кроме локальных сетей.${NC}" + echo -e "${CYAN}Split-профиль оставляет туннель по умолчанию, но выпускает напрямую${NC}" + echo -e "${CYAN}российские сервисы (те же группы, что и серверный bypass) и geoip:ru.${NC}" + echo -e "${CYAN}Иначе Ozon, банки и Госуслуги видят зарубежный адрес VPS.${NC}" + echo "" + + local current="не задан (управляемый профиль по умолчанию)" + if [[ -s "$HAPP_ROUTING_JSON_FILE" ]]; then + current=$(jq -r '.Name // "без имени"' "$HAPP_ROUTING_JSON_FILE" 2>/dev/null) || current="повреждён" + local n_direct + n_direct=$(jq -r '.DirectSites | length' "$HAPP_ROUTING_JSON_FILE" 2>/dev/null || echo "?") + current="${current} (${n_direct} доменов напрямую)" + fi + echo -e "${YELLOW}Текущий профиль:${NC} ${current}" + echo "" + echo -e "${CYAN} 1)${NC} Включить split-профиль для российских сервисов" + echo -e "${CYAN} 2)${NC} Вернуть профиль по умолчанию (удалить переопределение)" + echo -e "${CYAN} 0)${NC} Назад" + echo "" + echo -n -e "${YELLOW}Выберите действие: ${NC}" + local c + read -r c + + case "$c" in + 1) + local generated + if ! generated=$(_happ_client_split_routing_json); then + echo -e "${RED}✗ Не удалось собрать профиль${NC}" + sleep 2 + continue + fi + local n_direct + n_direct=$(jq -r '.DirectSites | length' <<< "$generated") + echo "" + echo -e "${CYAN}Напрямую пойдут ${n_direct} доменов и весь geoip:ru.${NC}" + echo -e "${CYAN}Всё остальное продолжит идти через VPN.${NC}" + echo -n -e "${YELLOW}Записать в ${HAPP_ROUTING_JSON_FILE}? [y/N]: ${NC}" + local confirm + read -r confirm + if [[ ! "$confirm" =~ ^[yYдД]$ ]]; then + echo -e "${CYAN}✓ Отменено${NC}" + sleep 1 + continue + fi + if [[ -s "$HAPP_ROUTING_JSON_FILE" ]]; then + cp -a "$HAPP_ROUTING_JSON_FILE" "${HAPP_ROUTING_JSON_FILE}.bak-$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true + fi + printf '%s\n' "$generated" > "$HAPP_ROUTING_JSON_FILE" || { + echo -e "${RED}✗ Не удалось записать файл${NC}" + sleep 2 + continue + } + chmod 644 "$HAPP_ROUTING_JSON_FILE" 2>/dev/null || true + echo -e "${GREEN}✓ Split-профиль записан${NC}" + echo -e "${CYAN} Клиенты получат его при следующем обновлении подписки.${NC}" + echo -e "${CYAN} Перезапуск xrayebator-sub.service не нужен: файл читается на каждый запрос.${NC}" + echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" + read -r _ + ;; + 2) + if [[ ! -e "$HAPP_ROUTING_JSON_FILE" ]]; then + echo -e "${CYAN}✓ Переопределения и так нет${NC}" + sleep 1 + continue + fi + echo -n -e "${YELLOW}Удалить ${HAPP_ROUTING_JSON_FILE}? [y/N]: ${NC}" + local confirm + read -r confirm + if [[ "$confirm" =~ ^[yYдД]$ ]]; then + mv "$HAPP_ROUTING_JSON_FILE" "${HAPP_ROUTING_JSON_FILE}.bak-$(date +%Y%m%d-%H%M%S)" 2>/dev/null \ + && echo -e "${GREEN}✓ Возвращён профиль по умолчанию${NC}" \ + || echo -e "${RED}✗ Не удалось удалить файл${NC}" + else + echo -e "${CYAN}✓ Отменено${NC}" + fi + sleep 2 + ;; 0) return ;; + *) echo -e "${RED}Неверный выбор${NC}"; sleep 1 ;; esac done } +# Миграция subhttp_hysteria2_2026 (мультибэкенд, срез 3): hysteria2-ссылка +# в обоих телах подписки. Регенерирует handler существующим repair-путём. +# На серверах без подписки — no-op: handler сгенерируется с hysteria2-кодом +# при будущей установке подписки (код уже в install_subscription_server). +_migrate_subhttp_hysteria2_2026() { + [[ -f /usr/local/etc/xray/.subhttp_hysteria2_2026 ]] && return 1 + if [[ -f /usr/local/etc/xray/.subscription_installed ]]; then + if ! _subscription_repair_handler; then + echo -e "${RED}✗ Не удалось регенерировать subhttp.sh (hysteria2)${NC}" + return 2 + fi + fi + return 0 +} + # Главное меню main_menu() { # Run migrations on first launch after upgrade. @@ -7007,6 +9056,7 @@ main_menu() { run_migration "subhttp_stale_profiles_410_2026" "HAPP subscription: stale profile diagnostics" _migrate_subhttp_stale_profiles_410_2026 || ((migration_failures++)) run_migration "subhttp_happ_xhttp_compat_2026" "HAPP subscription: XHTTP mode/no-limit compatibility" _migrate_subhttp_happ_xhttp_compat_2026 || ((migration_failures++)) run_migration "subhttp_happ_legacy_xhttp_2026" "HAPP subscription: prefer legacy XHTTP route" _migrate_subhttp_happ_legacy_xhttp_2026 || ((migration_failures++)) + run_migration "subhttp_hysteria2_2026" "Subhttp: hysteria2-ссылка в телах подписки (мультибэкенд)" _migrate_subhttp_hysteria2_2026 || ((migration_failures++)) # Aggregate report (RESEARCH §«Error reporting»): чтобы пользователь не пропустил # failure среди скроллящегося TUI вывода. @@ -7075,6 +9125,11 @@ main_menu() { echo -e "${CYAN} 9)${NC} Собственный домен / self-steal заглушка" echo -e "${CYAN}10)${NC} Поднять outbound-сервер (зарубежная нода)" echo "" + echo -e "${MAGENTA} MULTI-PROTOCOL BACKENDS:${NC}" + echo -e "${CYAN}11)${NC} Hysteria 2 (быстрый UDP)" + echo -e "${CYAN}12)${NC} AmneziaWG 2.0 (системный VPN)" + echo -e "${CYAN}13)${NC} Статус бэкендов" + echo "" echo -e "${RED} 0)${NC} Выход" echo "" echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" @@ -7091,6 +9146,9 @@ main_menu() { 8) cascade_mode_menu ;; 9) install_selfsteal_stub_menu ;; 10) setup_outbound_server_menu ;; + 11) hysteria2_menu ;; + 12) awg_menu ;; + 13) backend_status_menu ;; 0) exit 0 ;; *) echo -e "${RED}Неверный выбор${NC}"; sleep 1 ;; esac @@ -7573,6 +9631,8 @@ create_profile() { echo -e "${CYAN}Используйте меню 'Подключиться по профилю' для получения ссылки${NC}" echo "" fix_xray_permissions + # Мультибэкенд: гранты per-profile (hysteria2 и будущие) — после успешной Xray-части. + _backend_apply_profile_lifecycle "$name" created echo -n -e "${YELLOW}Нажмите Enter для продолжения...${NC}" read } @@ -8096,6 +10156,9 @@ _expire_disable_profile() { fi safe_jq_write --argjson saved "$saved" \ '.expire_clients = $saved | .expire_disabled = true' "$profile_file" || return 1 + # Мультибэкенд: событие expired — реген users-карты уберёт истёкший грант + # (фильтр expire_disabled в _hysteria2_regen_config). + _backend_apply_profile_lifecycle "$(basename "$profile_file" .json)" expired return 0 } @@ -8136,6 +10199,8 @@ _expire_enable_profile() { )' "$CONFIG_FILE" || return 1 fi safe_jq_write 'del(.expire_clients) | .expire_disabled = false' "$profile_file" || return 1 + # Мультибэкенд: событие restored — грант вернётся в users-карту автоматически. + _backend_apply_profile_lifecycle "$(basename "$profile_file" .json)" restored return 0 } @@ -9082,14 +11147,16 @@ change_sni_menu() { fi declare -A sni_desc - sni_desc["www.ozon.ru"]="E-commerce, макс. устойчивость для мобильных" + # Описания — по замеру 03.09.2026 (MegaFon): оговорки, не приговор — + # на других операторах/днях картина другая, перепроверяйте probe test. + sni_desc["www.ozon.ru"]="Замер 03.09 (MegaFon): не прошли — перепроверьте на своём операторе" sni_desc["api.ozon.ru"]="Ozon API, хорошо в паре с основным" sni_desc["st.ozon.ru"]="Ozon CDN статика" sni_desc["ir.ozon.ru"]="Ozon CDN изображения" - sni_desc["www.wildberries.ru"]="E-commerce, стабильно для Мегафон/Ростелеком" + sni_desc["www.wildberries.ru"]="Замер 03.09 (MegaFon): не прошли — перепроверьте" sni_desc["splitter.wb.ru"]="Wildberries CDN" sni_desc["www.sberbank.ru"]="Банк, оптимально для региональных сетей" - sni_desc["www.tinkoff.ru"]="Банк, стабильный вариант" + sni_desc["www.tinkoff.ru"]="Замер 03.09 (MegaFon): не прошли — перепроверьте" sni_desc["www.alfabank.ru"]="Банк, запасной вариант" sni_desc["www.nspk.ru"]="Нац. платёжная система, сложно блокировать" sni_desc["sbp.nspk.ru"]="СБП, идеально для МТС" @@ -9099,11 +11166,11 @@ change_sni_menu() { sni_desc["www.gosuslugi.ru"]="Госуслуги, для сложных регионов" sni_desc["www.mos.ru"]="Портал Москвы" sni_desc["speller.yandex.net"]="Яндекс CDN, универсальный вариант" - sni_desc["api-maps.yandex.ru"]="Яндекс Карты API" + sni_desc["api-maps.yandex.ru"]="Яндекс Карты API — прошли замер 03.09.2026" sni_desc["mc.yandex.ru"]="Яндекс Метрика" sni_desc["suggest.yandex.ru"]="Яндекс подсказки" sni_desc["www.github.com"]="Только если российские не работают!" - sni_desc["www.cloudflare.com"]="Для корпоративных каналов" + sni_desc["www.cloudflare.com"]="Прошли замер 03.09.2026, хорош для gRPC" sni_desc["aws.amazon.com"]="AWS CDN, запасной вариант для gRPC" echo -e "${YELLOW}ТОП-20 доменов для маскировки:${NC}\n" @@ -10482,6 +12549,17 @@ inspect_command() { flag=$(_geo_flag_emoji "$(_geo_country_code)") fi + # Мультибэкенд: сводка реестра. STRICT read-only: inspect ничего не создаёт — + # при отсутствии файла реестра просто пустой объект; секреты не включаем. + local backends_json="{}" + if [[ -s "$BACKENDS_REGISTRY_FILE" ]]; then + backends_json=$(jq -c \ + 'with_entries(.value |= {installed: (.value.installed // false), + version: (.value.version // null), port: (.value.port // null)})' \ + "$BACKENDS_REGISTRY_FILE" 2>/dev/null) || backends_json="{}" + [[ -n "$backends_json" ]] || backends_json="{}" + fi + jq -n \ --argjson ok true --argjson recognized true \ --arg os "$os" --arg manager "$manager" --arg xray "$xray" \ @@ -10494,12 +12572,13 @@ inspect_command() { --arg subscription_service "$sub_service" \ --arg subscription_url "$sub_url" \ --arg country "$geo_country" --arg city "$geo_city" --arg flag "$flag" \ + --argjson backends "$backends_json" \ '{ok:$ok, recognized:$recognized, os:$os, manager:$manager, xray:$xray, profiles:$profiles, profile_count:$profile_count, route_count:$route_count, subscription_installed:$subscription_installed, subscription_mode:$subscription_mode, subscription_domain:$subscription_domain, subscription_port:$subscription_port, subscription_service:$subscription_service, subscription_url:$subscription_url, - country:$country, city:$city, flag:$flag}' + country:$country, city:$city, flag:$flag, backends:$backends}' return 0 } @@ -10554,6 +12633,7 @@ quickstart_command() { run_migration "bypass_routing_2026" "Bypass routing first-run" _migrate_bypass_routing_2026 >/dev/null 2>&1 || true run_migration "subhttp_expired_410_2026" "Subhttp expired 410" _migrate_subhttp_expired_410_2026 >/dev/null 2>&1 || true run_migration "subhttp_happ_legacy_xhttp_2026" "Subhttp HAPP legacy XHTTP route" _migrate_subhttp_happ_legacy_xhttp_2026 >/dev/null 2>&1 || true + run_migration "subhttp_hysteria2_2026" "Subhttp hysteria2 links" _migrate_subhttp_hysteria2_2026 >/dev/null 2>&1 || true run_migration "subhttp_happ_xhttp_compat_2026" "Subhttp HAPP XHTTP compat" _migrate_subhttp_happ_xhttp_compat_2026 >/dev/null 2>&1 || true run_migration "subhttp_no_default_happ_routing_2026" "Subhttp no default HAPP routing profile" _migrate_subhttp_no_default_happ_routing_2026 >/dev/null 2>&1 || true run_migration "subhttp_stale_profiles_410_2026" "Subhttp stale profile 410 handler" _migrate_subhttp_stale_profiles_410_2026 >/dev/null 2>&1 || true @@ -10759,6 +12839,9 @@ NGINXHTTP # A8-fix: переиспользуем существующий действующий сертификат вместо повторного # certonly — иначе повторный quickstart упирается в Let's Encrypt rate-limit. # IP-серты shortlived (~6 дн.); renew-таймер продлевает их в фоне. + # SUB_TLS_MODE: ip_tls (LE-серт, HTTPS :8443) | http_tls (degraded fallback — + # HTTP :8080 наружу). Оба проксируют один и тот же локальный handler. + local SUB_TLS_MODE="ip_tls" local cert_dir="/etc/letsencrypt/live/$server_ip" local cert_expiry_ok=0 local cert_end="" cert_now="" cert_epoch="" @@ -10806,22 +12889,43 @@ NGINXHTTP >/tmp/certbot-ip.log 2>&1; then local certbot_reason certbot_reason=$(_certbot_failure_reason /tmp/certbot-ip.log) - # Fallback: certonly упёрся в rate limit (или временную сетевую ошибку), но + # Fallback 1: certonly упёрся в rate limit (или временную сетевую ошибку), но # старый серт всё ещё числится действительным — переиспользуем его, чтобы # quickstart не падал, а renew-таймер продолжит попытки продления в фоне. if [[ -f "$cert_dir/fullchain.pem" && -f "$cert_dir/privkey.pem" ]] && \ [[ -n "$cert_epoch" && "$cert_epoch" -gt "$cert_now" ]]; then echo " -> certbot не смог выпустить новый серт, но действующий есть — переиспользую его (${cert_end})" >&2 else + # Fallback 2 (http_tls): hoster-уровневая блокировка http-01 (LE validation + # получает "Connection reset by peer" — порт 80 фильтруется upstream до нашего + # стека; подтверждено tcpdump + check-host на VPS 150.241.65.101, 2026-10-08). + # Вместо dead-end: поднимаем подписку по HTTP (handler на 8080 открыт наружу, + # 404 без токена, токен — bearer) с self-signed сертом для Hysteria и честным + # маркером degraded в JSON. Повторный quickstart ПОСЛЕ разблокировки :80 у + # хостера выдаст LE-серт и вернёт ip_tls (идемпотентен). + SUB_TLS_MODE="http_tls" + mkdir -p "/etc/letsencrypt/selfsigned/${server_ip}" + if ! openssl ecparam -genkey -name prime256v1 \ + -out "/etc/letsencrypt/selfsigned/${server_ip}/privkey.pem" 2>/dev/null || \ + ! openssl req -new -x509 -days 3650 \ + -key "/etc/letsencrypt/selfsigned/${server_ip}/privkey.pem" \ + -out "/etc/letsencrypt/selfsigned/${server_ip}/fullchain.pem" \ + -subj "/CN=${server_ip}" 2>/dev/null; then + echo '{"ok":false,"error":"certbot failed и self-signed fallback не создался. Log: /tmp/certbot-ip.log"}' + _qs_nginx_rollback + return 1 + fi + chmod 600 "/etc/letsencrypt/selfsigned/${server_ip}/privkey.pem" 2>/dev/null || true if [[ -n "$certbot_reason" ]]; then - echo "{\"ok\":false,\"error\":\"certbot failed: ${certbot_reason}\"}" - else - echo '{"ok":false,"error":"certbot failed. Log: /tmp/certbot-ip.log"}' + echo " -> certbot: ${certbot_reason}" >&2 fi - _qs_nginx_rollback - return 1 + echo " ⚠ LE не смог проверить http-01 (порт 80 заблокирован upstream хостера)." >&2 + echo " ⚠ Подписка разворачивается по HTTP (fallback): http://${server_ip}:8080/sub/" >&2 + echo " Токен URL остаётся bearer-credential; повторите quickstart после разблокировки :80 —" >&2 + echo " тогда будет выпущен LE-серт и подписка вернётся на HTTPS." >&2 fi else + SUB_TLS_MODE="ip_tls" echo " TLS сертификат получен" >&2 # P0-certbot-fix: регистрируем cert в root-owned манифесте (uninstall удалит только его). _certbot_register "$server_ip" @@ -10844,7 +12948,27 @@ NGINXHTTP fi fi - # Nginx HTTPS config (проксирует на subscription server, порт 8443 — Xray на 443) + # Nginx config: https (ip_tls: LE-серт, прокси на 8080) или http (http_tls + # fallback: 8080 открыт напрямую, ACME-challenge на :80 остаётся для + # будущего апгрейда до LE-серта повторным quickstart). + if [[ "$SUB_TLS_MODE" == "http_tls" ]]; then + cat > "$NGINX_SITES_AVAILABLE/xrayebator-sub" << NGINXHTTPSUB +server { + listen 80; + listen [::]:80; + server_name ${server_ip_bracketed}; + # MUST serve ACME http-01 challenge: повторный quickstart после разблокировки + # :80 выпустит LE-серт и переведёт подписку обратно на HTTPS (ip_tls). + location ^~ /.well-known/acme-challenge/ { + root ${webroot}; + default_type text/plain; + } + location / { + return 404; + } +} +NGINXHTTPSUB + else cat > "$NGINX_SITES_AVAILABLE/xrayebator-sub" << NGINXHTTPS limit_req_zone \$binary_remote_addr zone=xrayebator_sub:10m rate=30r/m; @@ -10858,6 +12982,12 @@ server { ssl_ciphers HIGH:!aNULL:!MD5; location / { limit_req zone=xrayebator_sub burst=5 nodelay; + # HAPP routing-профиль легко перерастает дефолтные 4k заголовка: + # без буферов nginx отвечает 502 "upstream sent too big header". + # (Идея из PR #23, tony-adamson; латынь та же, что в доменном TLS-шаблоне.) + proxy_buffer_size 32k; + proxy_buffers 8 32k; + proxy_busy_buffers_size 64k; proxy_pass http://127.0.0.1:8080; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; @@ -10885,19 +13015,25 @@ server { } } NGINXHTTPS - nginx -t >/dev/null 2>&1 || { echo '{"ok":false,"error":"nginx HTTPS config failed"}'; _qs_nginx_rollback; return 1; } + fi + nginx -t >/dev/null 2>&1 || { echo '{"ok":false,"error":"nginx config failed"}'; _qs_nginx_rollback; return 1; } if ! systemctl reload nginx >/dev/null 2>&1; then - echo '{"ok":false,"error":"nginx не перечитал HTTPS config — подписка недоступна"}' + echo '{"ok":false,"error":"nginx не перечитал config — подписка недоступна"}' _qs_nginx_rollback return 1 fi - # Пишем маркеры подписки (ip_tls mode) — иначе _subscription_base_url вернёт - # локальный http://127.0.0.1:8080 и GUI/happ-setup покажут неверный URL и mode "unknown". - _subscription_write_markers "8443" "$server_ip" "$server_ip" "ip_tls" + # Пишем маркеры подписки: ip_tls (LE-серт на :8443) или http_tls (fallback — + # handler 8080 напрямую). Иначе _subscription_base_url вернёт локальный + # http://127.0.0.1:8080 и GUI/happ-setup покажут неверный URL и mode "unknown". + _subscription_write_markers "8443" "$server_ip" "$server_ip" "$SUB_TLS_MODE" - # Автопродление shortlived IP-сертификатов (~6 дней). - _install_ip_cert_renew_timer "$certbot_bin" "$server_ip" "$webroot" + # Автопродление shortlived IP-сертификатов (~6 дней) — только в ip_tls: + # в http_tls серта нет, и таймер certbot renew каждый раз падал бы по тому же + # upstream-фильтру, который и вызвал fallback. + if [[ "$SUB_TLS_MODE" == "ip_tls" ]]; then + _install_ip_cert_renew_timer "$certbot_bin" "$server_ip" "$webroot" + fi # 4. Создание профиля echo "=== Профиль ===" >&2 @@ -10947,7 +13083,7 @@ NGINXHTTPS fi subscription_url="${base}/sub/${result_sub_token}" -# «Сторона» сервера для GUI (country/city/flag) + ОС (PRETTY_NAME) — полезно "Мои серверы". + # «Сторона» сервера для GUI (country/city/flag) + ОС (PRETTY_NAME) — полезно "Мои серверы". local result_country result_city result_flag result_os result_country=$(_geo_country_name) result_city=$(_geo_city) @@ -10962,6 +13098,21 @@ NGINXHTTPS QS_DEFAULT_REMOVED=false QS_DEFAULT_BACKUP_CREATED=false + # degraded=true только в http_tls-fallback: сервер работает, но подписка по HTTP + # (LE не смог проверить http-01 — обычно порт 80 фильтруется хостером). GUI + # сохраняет сервер с setupStatus=partial и показывает понятную причину. + if [[ "$SUB_TLS_MODE" == "http_tls" ]]; then + jq -n \ + --arg url "$subscription_url" --arg base "$base" \ + --arg profile "$HAPP_DEFAULT_PROFILE" --argjson routes "$route_count" \ + --arg country "$result_country" --arg city "$result_city" \ + --arg flag "$result_flag" --arg os "$result_os" \ + --arg cert_reason "${certbot_reason:-}" \ + '{ok:true, degraded:true, tls_mode:"http_tls", certbot_reason:$cert_reason, + subscription_url:$url, base_url:$base, profile:$profile, routes:$routes, + country:$country, city:$city, flag:$flag, os:$os}' + return 0 + fi echo "{\"ok\":true,\"subscription_url\":\"${subscription_url}\",\"base_url\":\"${base}\",\"profile\":\"${HAPP_DEFAULT_PROFILE}\",\"routes\":${route_count},\"country\":\"${result_country}\",\"city\":\"${result_city}\",\"flag\":\"${result_flag}\",\"os\":\"${result_os}\"}" return 0 } @@ -11099,6 +13250,11 @@ profiles_command() { fi [[ "$rcount" =~ ^[0-9]+$ ]] || rcount=0 + # Мультибэкенд: аддитивный объект .backends профиля (гранты hysteria2/awg). + local backends + backends=$(jq -c '.backends // {}' "$file" 2>/dev/null) + [[ -n "$backends" ]] || backends="{}" + local sub_url="" if _profile_cli_subscription_url "$sub_token" >/dev/null 2>&1; then sub_url="$base/sub/$sub_token" @@ -11112,12 +13268,13 @@ profiles_command() { --argjson pq "$pq" --arg subscription_url "$sub_url" \ --argjson expire "$expire" --arg expire_date "$expire_date" \ --argjson expire_disabled "$expire_disabled" \ + --argjson backends "$backends" \ '{name: $name, uuid: $uuid, transport: $transport, port: $port, fingerprint: $fingerprint, sni: $sni, created: $created, sub_token: $sub_token, multi_route: $multi_route, routes: $routes, pq_enabled: $pq, subscription_url: $subscription_url, expire: $expire, expire_date: $expire_date, expire_disabled: $expire_disabled, - expire_supported: true}' + expire_supported: true, backends: $backends}' done printf ']\n' return 0 @@ -11316,6 +13473,8 @@ _profile_cli_create_one() { fi fix_xray_permissions + # Мультибэкенд: грант hysteria2 новому профилю (после успешной Xray-части). + _backend_apply_profile_lifecycle "$pname" created local sub_url="" if _profile_cli_subscription_url "$sub_token" >/dev/null 2>&1; then sub_url="$(_subscription_base_url)/sub/${sub_token}" @@ -11367,17 +13526,19 @@ profile_delete_command() { return 1 fi - local rollback_config="" - if ! backup_config "cli_delete_$name" rollback_config; then - echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' - return 1 - fi - # CLI-режим: статусы helpers (backup_config, safe_restart_xray, close_firewall_port) # печатают цветной мусор в stdout и ломали JSON-парсинг в GUI — душим их в stderr. + # Redirect ДО backup_config: её цветная строка тоже мусор для парсера GUI. exec 3>&1 exec 1>&2 + local rollback_config="" + if ! backup_config "cli_delete_$name" rollback_config; then + echo '{"ok":false,"error":"Не удалось создать бэкап config.json"}' >&3 + exec 1>&3 3>&- + return 1 + fi + if ! _remove_profile_uuid_from_config "$uuid" "$ports_json"; then echo '{"ok":false,"error":"Ошибка обновления конфига"}' >&3 exec 1>&3 3>&- @@ -11407,6 +13568,8 @@ profile_delete_command() { done < <(jq -r '.[]' <<< "$ports_json") fix_xray_permissions + # Мультибэкенд: снятие грантов удалённого профиля (реген по оставшимся). + _backend_apply_profile_lifecycle "$name" deleted echo "{\"ok\":true,\"name\":\"$name\"}" return 0 } @@ -11490,8 +13653,10 @@ profile_revoke_command() { local jq_expr='.sub_token = $t' if [[ "$full" == "true" ]]; then jq_args+=(--arg old "$old_uuid" --arg new "$new_uuid") - jq_expr+=' | .uuid = $new | (.routes // []) |= map(if .uuid? == $old then .uuid = $new else . end)' - # routes не хранят uuid (общий на профиль), но выражение безопасно для будущих схем. + # ВАЖНО: (.routes // []) |= map(...) — невалидный path в jq («Invalid path + # expression with result []» на single-route профилях, где routes = null). + # Каноничный guard has("routes") — как в update_all_profiles_on_port. + jq_expr+=' | .uuid = $new | if has("routes") then .routes |= map(if .uuid? == $old then .uuid = $new else . end) else . end' if ! _replace_uuid_in_config "$old_uuid" "$new_uuid" "$ports_json"; then echo '{"ok":false,"error":"Не удалось обновить clients в config.json"}' >&3 @@ -11523,6 +13688,8 @@ profile_revoke_command() { fix_xray_permissions exec 1>&3 3>&- + # Мультибэкенд: ротация грантов (hysteria2-пароль) при отзыве. + _backend_apply_profile_lifecycle "$name" revoked local sub_url="" base base=$(_subscription_base_url 2>/dev/null || true) if [[ -n "$base" ]] && _profile_cli_subscription_url "$new_token" >/dev/null 2>&1; then @@ -12634,6 +14801,53 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then shift bypass_command "$@" ;; + backend-status) + _backend_status_json + ;; + hysteria2-install) + shift + hysteria2_install_command "$@" + ;; + hysteria2-uninstall) + hysteria2_uninstall_command + ;; + hysteria2-status) + hysteria2_status_command + ;; + hysteria2-grant) + shift + hysteria2_grant_command "$@" + ;; + hysteria2-subbody) + shift + hysteria2_subbody_command "$@" + ;; + hysteria2-link) + shift + hysteria2_link_command "$@" + ;; + awg-install) + shift + awg_install_command "$@" + ;; + awg-uninstall) + awg_uninstall_command + ;; + awg-status) + awg_status_command + ;; + awg-grant) + shift + awg_grant_command "$@" + ;; + awg-conf) + shift + awg_conf_command "$@" + ;; + awg-31) + shift + awg_31_command "$@" + ;; ""|menu) main_menu ;; @@ -12664,11 +14878,25 @@ if [[ "$XRAYEBATOR_SOURCED" -eq 0 ]]; then echo -e " ${CYAN}sudo xrayebator bypass remove --domain D${NC} — убрать домен из обхода (JSON)" echo -e " ${CYAN}sudo xrayebator bypass reset${NC} — сбросить все bypass-правила (JSON)" echo -e " ${CYAN}sudo xrayebator bypass bundle [--group a,b,c]${NC} — применить группы доменов (JSON)" + echo -e " ${CYAN}sudo xrayebator backend-status${NC} — статус мультипротокольных бэкендов (JSON)" + echo -e " ${CYAN}sudo xrayebator hysteria2-install [--port P]${NC} — установить Hysteria 2 (JSON)" + echo -e " ${CYAN}sudo xrayebator hysteria2-uninstall${NC} — удалить Hysteria 2 (JSON)" + echo -e " ${CYAN}sudo xrayebator hysteria2-status${NC} — статус Hysteria 2 (JSON)" + echo -e " ${CYAN}sudo xrayebator hysteria2-grant --name N${NC} — выдать грант профилю (JSON)" + echo -e " ${CYAN}sudo xrayebator hysteria2-subbody --on|--off${NC} — hysteria2-строки в подписке (JSON)" + echo -e " ${CYAN}sudo xrayebator hysteria2-link --name N${NC} — hysteria2-ссылка профиля (JSON)" + echo -e " ${CYAN}sudo xrayebator awg-install${NC} — установить AmneziaWG 2.0 (JSON)" + echo -e " ${CYAN}sudo xrayebator awg-uninstall${NC} — удалить AmneziaWG (JSON)" + echo -e " ${CYAN}sudo xrayebator awg-status${NC} — статус AmneziaWG (JSON)" + echo -e " ${CYAN}sudo xrayebator awg-grant --name N${NC} — выдать peer-грант профилю (JSON)" + echo -e " ${CYAN}sudo xrayebator awg-conf --name N${NC} — клиентский .conf профиля (JSON)" + echo -e " ${CYAN}sudo xrayebator awg-31 --on|--off${NC} — режим AWG 3.1 (HeaderProtection+RandomTrailers; JSON)" echo "" echo -e "${YELLOW}Файлы:${NC}" echo -e " config: /usr/local/etc/xray/config.json" echo -e " profiles: /usr/local/etc/xray/profiles/" echo -e " backups: /usr/local/etc/xray/backups/" + echo -e " backends: /usr/local/etc/xrayebator/backends.json" echo -e " docs: https://github.com/howdeploy/Xrayebator" exit 0 ;;